安卓数据库编程-全-

安卓数据库编程(全)

原文:zh.annas-archive.org/md5/178BF5D3B8A98AFC3DB2CE2ED8D821E4

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

今天,我们生活在一个日益以数据和数据驱动为中心的世界。我们生活在一个像亚马逊这样的公司追踪我们查看的每个商品和我们购买的每个商品,以便向我们推荐类似产品的世界。我们生活在一个像谷歌这样的公司存储投向它们的每个搜索查询,以便在未来推荐更好的搜索查询的世界。我们生活在一个像 Facebook 这样的社交媒体网站记住我们与朋友分享的每个事件和每个想法,以便更好地了解其数亿用户中的每一个的世界。我们生活在一个日益以数据为中心的世界,因此,我们必须以数据为中心的角度开发应用程序,这是至关重要的。

看看周围——近几年来,移动设备(如智能手机和平板电脑)的增长速度惊人。本书旨在探索数据和 Android,快速深入了解谷歌团队为 Android 操作系统构建的各种方法。本书不仅力求展示所有可用的数据存储方法,还力求阐明每种方法的优点和缺点。通过阅读本书,我的目标是使你能够打造一个高效、设计良好且可扩展的数据中心应用程序。

本书涵盖内容

第一章,在 Android 上存储数据,重点介绍 Android 上所有不同的本地数据存储方法。本章提供了各种存储方法的充足代码示例,并比较了每种方法的优缺点。

第二章,使用 SQLite 数据库,深入探讨了最复杂和最常用的本地数据存储形式——SQLite 数据库——通过引导你实现一个自定义的 SQLite 数据库。

第三章,SQLite 查询,旨在对 SQL 查询语言进行概览。它教会读者如何构建强大的数据库查询,这些查询可以与任何 SQLite 数据库一起使用。

第四章,使用内容提供者,通过展示如何通过使用内容提供者将数据库暴露给整个 Android 操作系统,扩展了前面的 SQLite 数据库章节。它引导读者完成一个完整的内容提供者的实现,并以讨论使数据公开的好处作为结束。

第五章,查询联系人表,专门探讨 Android 操作系统提供的最广泛使用的内容提供者——联系人内容提供者。它探索了联系人表的结构,并提供了一些常见查询的示例。

第六章,绑定到 UI,讨论了用户可以将数据绑定到用户界面的方法。由于数据通常显示为列表,这一章通过两种类型列表适配器的实现来进行讲解。

第七章,实践中的 Android 数据库,试图脱离编程,专注于更高层次的设计概念。它讨论了到目前为止所讨论的所有本地存储方法的使用方式,并强调了这些本地方法的不足之处——为接下来几章,我们关注外部数据存储,打开大门。

第八章,探索外部数据库,引入了使用外部数据库的概念,并列出了读者可以使用的一些常见外部数据存储。这一章以如何设置 Google App Engine 数据存储的示例作为结束。

第九章,收集和存储数据,通过讨论应用程序可以如何去收集数据,然后可以将这些数据插入到新的外部数据库中,来扩展上一章的开发。收集数据的方法包括使用可用的 API,以及编写自定义的网络爬虫。

第十章,综合应用,通过展示如何首先创建 HTTP 服务端程序,然后从移动应用程序向这些 HTTP 服务端程序发起 HTTP 请求,完成了在之前两章中开始的应用程序。这一章是本书的高潮,向读者展示了如何将移动应用程序与外部数据库连接,并最终解析和显示 HTTP 响应作为列表。

阅读本书所需的条件

本书的准备工作包括对 Android 操作系统的基本了解,能够创建 Android 和 Google App Engine 项目的编程 IDE(即 Eclipse),以及能够进行基本网络请求的稳定互联网连接。

本书的目标读者

本书面向那些对数据库和其他后端设计概念有一定经验,但可能想要将这些概念应用于移动应用程序的开发者。对于那些在移动应用程序和/或 Android 平台上经验丰富,但可能不太熟悉后端系统和设计/实施数据库模式的开发者,也会发现这本书很有用。

即使对于那些已经在 Android 编程和数据库实施方面有经验的开发者来说,这本书也可能有助于进一步巩固概念,并展示 Android 上数据存储方法的更广泛范围。

约定

在这本书中,你会发现多种文本样式,用于区分不同类型的信息。以下是一些样式示例,以及它们含义的解释。

文本中的代码词汇如下所示:"它会将我们想要写入的字符串转换为字节形式,然后传递给输出流的write()方法。"

代码块如下设置:

Set<String> values = new HashSet<String>();
values.add("Hello");
values.add("World");
Editor e = sp.edit();
e.putStringSet("strSetKey", values);
e.commit();
Set<String> ret = sp.getStringSet(values, new HashSet<String>());
for(String r : ret) {
Log.i("SharedPreferencesExample", "Retrieved vals: " + r);
}

当我们希望引起你对代码块中某个特定部分的注意时,相关的行或项目会以粗体显示:

<uses-sdk android:minSdkVersion="5" />
<uses-permission android:name="android.permission.READ_CONTACTS"/>
<uses-permission android:name="android.permission.WRITE_CONTACTS"/>

任何命令行输入或输出都如下编写:

adb -s emulator-xxxx shell

新术语重要词汇以粗体显示。

注意

警告或重要信息会以如下框中的形式出现。

注意

技巧和诀窍如下所示。

读者反馈

我们欢迎读者的反馈。告诉我们你对这本书的看法——你喜欢什么,或者可能不喜欢什么。读者的反馈对我们开发能让你们充分利用的标题非常重要。

要给我们发送一般反馈,只需发送电子邮件到<feedback@packtpub.com>,并在邮件的主题中提及书名。

如果你对某个主题有专业知识并且有兴趣撰写或参与书籍编写,请查看我们在www.packtpub.com/authors上的作者指南。

客户支持

既然你现在拥有了 Packt 的一本书,我们有很多方法可以帮助你最大限度地利用你的购买。

下载示例代码

你可以从你的账户www.packtpub.com下载你所购买的所有 Packt 图书的示例代码文件。如果你在其他地方购买了这本书,可以访问www.packtpub.com/support注册,我们会直接将文件通过电子邮件发送给你。

勘误

尽管我们已经尽力确保我们内容的准确性,但错误仍然会发生。如果你在我们的书中发现了一个错误——可能是文本或代码中的错误——如果你能报告给我们,我们将不胜感激。这样做,你可以避免其他读者的困扰,并帮助我们改进这本书的后续版本。如果你发现任何勘误,请通过访问www.packtpub.com/support,选择你的书籍,点击勘误表提交表单链接,并输入你的勘误详情。一旦你的勘误被验证,你的提交将被接受,勘误将被上传到我们的网站,或添加到该标题勘误部分现有的勘误列表中。

盗版

网络上版权资料的盗版问题在所有媒体中持续存在。在 Packt,我们非常重视保护我们的版权和许可。如果你在互联网上发现我们作品的任何非法副本,无论何种形式,请立即提供位置地址或网站名称,以便我们可以寻求补救措施。

如果发现疑似盗版资料,请通过<copyright@packtpub.com>联系我们,并提供相关链接。

我们感谢你帮助保护我们的作者,以及我们为你提供有价值内容的能力。

问题

如果您在阅读本书的任何方面遇到问题,可以通过<questions@packtpub.com>联系我们,我们将尽力解决。

第一章:在 Android 上存储数据

今天,我们生活在一个日益以数据为中心和数据驱动的社会中。我们生活在一个像亚马逊这样的公司追踪我们查看的每一个商品和我们购买的每一个商品,以便向我们推荐类似商品的世界。我们生活在一个像谷歌这样的公司存储每一个向他们提出的搜索查询,以便未来推荐更好的搜索查询的世界。我们生活在一个像 Facebook 这样的社交媒体网站记住我们与朋友分享的每一个事件和每一个想法,以便更好地了解他们数亿用户中的每一个的世界。我们生活在一个日益以数据为中心的世界中,因此,我们必须以数据为中心的视角开发应用程序,这是至关重要的。

你可能会问,为什么是 Android?或者更普遍地问,为什么是移动应用?看看你周围,近几年来,移动设备的增长,如智能手机和平板电脑,已经爆炸式增长。此外,移动设备隐含地为我们提供了之前在桌面应用中没有的另一层数据。当你随身携带智能手机或平板电脑时,它知道你的位置,知道你在哪里签到以及你在做什么;简而言之,它知道的你比你意识到的要多得多。

在记住这两个要点的同时,我们从数据和 Android 的角度开始探索,快速深入了解谷歌的工程师们为 Android 操作系统内置的各种方法。本书假设读者对 Android 操作系统有一定的经验,因为我们将直接进入代码。现在,了解你可以使用的所有不同的数据存储方法很重要,但同样重要的是要了解每种方法的优点和缺点,这样你才能构建一个高效、设计良好且可扩展的应用程序。

使用 SharedPreferences

SharedPreferences是在你的 Android 应用程序中存储本地数据最简单、快捷、高效的方式。它本质上是一个允许你存储和关联各种键值对与你的应用程序的框架(可以把它看作是随应用程序附带的地图,你可以随时利用它),因为每个应用程序都与其自己的SharedPreferences类关联,所以存储和提交的数据在所有用户会话中都是持久的。然而,由于其简单和高效的本质,SharedPreferences只允许你保存基本数据类型(即布尔值、浮点数、长整数、整数和字符串),因此在决定将什么作为共享偏好存储时要记住这一点。

让我们看看你如何访问和使用应用程序的SharedPreferences类的一个例子:

public class SharedPreferencesExample extends Activity {
private static final String MY_DB = "my_db";
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.main);
// INSTANTIATE SHARED PREFERENCES CLASS
SharedPreferences sp = getSharedPreferences(MY_DB,
Context.MODE_PRIVATE);
// LOAD THE EDITOR REMEMBER TO COMMIT CHANGES!
Editor e = sp.edit();
e.putString("strKey", "Hello World");
e.putBoolean("boolKey", true);
e.commit();
String stringValue = sp.getString("strKey", "error");
boolean booleanValue = sp.getBoolean("boolKey", false);
Log.i("LOG_TAG", "String value: " + stringValue);
Log.i("LOG_TAG ", "Boolean value: " + booleanValue);
}
}

让我们逐步了解这段小代码片段中发生的事情。首先,我们启动一个Activity,在onCreate()方法中,我们请求获取一个SharedPreferences类。getSharedPreferences()方法的参数是:

getSharedPreferences(String mapName, int mapMode)

在这里,第一个参数简单地指定你想要哪个共享偏好设置映射(每个应用程序可以拥有几个独立的共享偏好设置映射,因此,就像在数据库中指定表名一样,你必须指定要检索哪个映射)。第二个参数稍微复杂一些——在上面的例子中,我们传入MODE_PRIVATE作为参数,这个参数只是指定你正在检索的共享偏好设置实例的可见性(在这种情况下,可见性设置为私有,只有你的应用程序可以访问映射内容)。其他模式包括:

  • MODE_WORLD_READABLE: 使你的地图对其他应用程序可见,尽管内容只能读取。

  • MODE_WORD_WRITEABLE: 使你的地图对其他应用程序可见,并可用于读取和写入。

  • MODE_MULTI_PROCESS: 此模式自 API Level 11 起可用,允许你通过多个进程修改地图,这些进程可能会写入同一个共享偏好设置实例。

现在,一旦我们有了共享偏好设置对象,就可以立即通过其各种get()方法检索内容——比如我们之前看到的getString()getBoolean()方法。这些get()方法通常需要两个参数:第一个是,第二个是如果找不到给定键时的默认值。以上一个例子为例,我们有:

String stringValue = sp.getString("strKey", "error");
boolean booleanValue = sp.getBoolean("boolKey", false);

因此,在第一个案例中,我们尝试检索与键strKey关联的字符串值,如果找不到这样的键,则默认为字符串error。同样,在第二个案例中,我们尝试检索与键boolKey关联的布尔值,如果找不到这样的键,则默认为布尔值false

但是,如果你想编辑内容或添加新内容,那么你需要检索每个共享偏好设置实例中包含的Editor对象。这个Editor对象包含了所有允许你传递键及其关联值的put()方法(就像你对标准Map对象所做的那样)——唯一需要注意的是,在添加或更新共享偏好设置的内容后,你需要调用Editor对象的commit()方法来保存这些更改。此外,同样地,就像标准Map对象一样,Editor类也包含remove()clear()方法,让你自由地操作共享偏好设置的内容。

在我们继续讨论SharedPreferences的典型用例之前,需要记住的最后一件事是,如果你决定将共享偏好实例的可见性设置为MODE_WORLD_WRITEABLE,那么你可能会因为恶意外部应用程序而面临各种安全漏洞。因此,实际上,不推荐使用这种模式。然而,许多开发人员仍然面临在两个应用程序之间本地共享信息的愿望,因此,开发了一种简单的方法,只需在应用程序的清单文件中设置一个android:sharedUserId即可实现。

这个工作原理是,每个签名并导出的应用程序都会获得一个自动生成的应用程序 ID。但是,如果你在应用程序的清单文件中明确设置此 ID,那么假设有两个应用程序使用相同的密钥签名,它们将能够自由访问彼此的数据,而无需将数据暴露给用户手机上的其他应用程序。换句话说,通过为两个应用程序设置相同的 ID,只有这两个应用程序能够访问彼此的数据。

SharedPreferences 的常见用例

既然我们已经知道如何实例化和编辑共享偏好对象,那么考虑这种数据存储类型的典型用例是很重要的。因此,以下是几个示例,说明应用程序倾向于保存哪些类型的小型、原始的键值对数据。

检查这是否是用户第一次访问你的应用程序

对于许多应用程序来说,如果这是用户的第一次访问,那么他们可能希望显示一些说明/教程活动或启动屏幕活动:

public class SharedPreferencesExample2 extends Activity {
private static final String MY_DB = "my_db";
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.main);
SharedPreferences sp = getSharedPreferences(MY_DB,
Context.MODE_PRIVATE);
/**
* CHECK IF THIS IS USER'S FIRST VISIT
*/
boolean hasVisited = sp.getBoolean("hasVisited",
false);
if (!hasVisited) {
// ...
// SHOW SPLASH ACTIVITY, LOGIN ACTIVITY, ETC
// ...
// DON'T FORGET TO COMMIT THE CHANGE!
Editor e = sp.edit();
e.putBoolean("hasVisited", true);
e.commit();
}
}
}

检查应用程序上次更新时间

许多应用程序内置了某种缓存或同步功能,这将需要定期更新。通过保存上次更新时间,我们可以快速检查已经过去了多少时间,并决定是否需要进行更新/同步:

提示

下载示例代码

你可以从你的账户下载你购买的所有 Packt 图书的示例代码文件,网址是www.packtpub.com。如果你在其他地方购买了这本书,可以访问www.packtpub.com/support注册,我们会直接将文件通过电子邮件发送给你。

/**
* CHECK LAST UPDATE TIME
*/
long lastUpdateTime = sp.getLong("lastUpdateKey", 0L);
long timeElapsed = System.currentTimeMillis() -
lastUpdateTime;
// YOUR UPDATE FREQUENCY HERE
final long UPDATE_FREQ = 1000 * 60 * 60 * 24;
if (timeElapsed > UPDATE_FREQ) {
// ...
// PERFORM NECESSARY UPDATES
// ...
}
// STORE LATEST UPDATE TIME
Editor e = sp.edit();
e.putLong("lastUpdateKey", System.currentTimeMillis());
e.commit();

记住用户的登录用户名

许多应用程序将允许用户记住他们的用户名(以及其他登录相关的字段,如 PIN 码、电话号码等),而共享偏好是存储简单原始字符串 ID 的好方法:

/**
* CACHE USER NAME AS STRING
*/
// TYPICALLY YOU WILL HAVE AN EDIT TEXT VIEW
// WHERE THE USER ENTERS THEIR USERNAME
EditText userNameLoginText = (EditText)
findViewById(R.id.login_editText);
String userName =
userNameLoginText.getText().toString();
Editor e = sp.edit();
e.putString("userNameCache", userName);
e.commit();

记住应用程序的状态

对于许多应用程序,应用程序的功能会根据应用程序的状态而改变,通常由用户设置。以电话铃声应用程序为例——如果用户指定在静音模式下不执行任何功能,那么这很可能是一个需要记住的重要状态:

/**
* REMEBERING A CERTAIN STATE
*/
boolean isSilentMode = sp.getBoolean("isSilentRinger",
false);
if (isSilentMode) {
// ...
// TURN OFF APPLICATION
// ...
}

缓存用户的位置

任何基于位置的应用程序通常都会因为多种原因想要缓存用户的最后位置(可能用户关闭了 GPS,或者信号弱等)。这可以通过将用户的纬度和经度转换为浮点数,然后存储在共享偏好设置实例中轻松完成:

/**
* CACHING A LOCATION
*/
// INSTANTIATE LOCATION MANAGER
LocationManager locationManager = (LocationManager)
this.getSystemService(Context.LOCATION_SERVICE);
// ...
// IGNORE LOCATION LISTENERS FOR NOW
// ...
Location lastKnownLocation =
locationManager.getLastKnownLocation
(LocationManager.NETWORK_PROVIDER);
float lat = (float) lastKnownLocation.getLatitude();
float lon = (float) lastKnownLocation.getLongitude();
Editor e = sp.edit();
e.putFloat("latitudeCache", lat);
e.putFloat("longitudeCache", lon);
e.commit();

在最新版本的 Android(API 级别 11)中,还有一个新的getStringSet()方法,它允许你为给定的关联键设置和检索一组字符串对象。以下是它的实际应用:

Set<String> values = new HashSet<String>();
values.add("Hello");
values.add("World");
Editor e = sp.edit();
e.putStringSet("strSetKey", values);
e.commit();
Set<String> ret = sp.getStringSet(values, new HashSet<String>());
for(String r : ret) {
Log.i("SharedPreferencesExample", "Retrieved vals: " + r);
}

这种情况的用例很多——但现在让我们继续。

内部存储方法

让我们从 Android 的内部存储机制开始。对于那些有标准 Java 编程经验的用户,这一部分会非常自然。Android 上的内部存储允许你读取和写入与每个应用程序内部存储关联的文件。这些文件只能由应用程序访问,其他应用程序或用户无法访问。此外,当应用程序被卸载时,这些文件也会自动删除。

下面的例子展示了如何访问应用程序的内部存储:

public class InternalStorageExample extends Activity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.main);
// THE NAME OF THE FILE
String fileName = "my_file.txt";
// STRING TO BE WRITTEN TO FILE
String msg = "Hello World.";
try {
// CREATE THE FILE AND WRITE
FileOutputStream fos = openFileOutput(fileName,
Context.MODE_PRIVATE);
fos.write(msg.getBytes());
fos.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}

这里我们简单使用了Context类的openFileOutput()方法,它第一个参数是待创建(或覆盖)的文件名,第二个参数是文件的可见性(与SharedPreferences类似,你可以控制文件的可见性)。然后它将我们想要写入的字符串转换为字节形式,并传递给输出流的write()方法。不过有一点需要提及,可以使用openFileOutput()指定一个额外的模式,即:

  • MODE_APPEND: 这个模式允许你打开一个已存在的文件,并将字符串追加到其现有内容之后(使用其他任何模式,现有内容将被删除)

此外,如果你在 Eclipse 中编程,那么你可以进入DDMS屏幕,查看应用程序的内部文件(以及其他内容)。

内部存储方法

我们可以看到刚刚创建的文本文件。对于那些在终端进行开发的用户,这个文件的路径会是/data/data/{your-app-path}/files/my_file.txt。不幸的是,读取文件要复杂得多,相应的代码如下所示:

public class InternalStorageExample2 extends Activity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.main);
// THE NAME OF THE FILE
String fileName = "my_file.txt";
try {
// OPEN FILE INPUT STREAM THIS TIME
FileInputStream fis = openFileInput(fileName);
InputStreamReader isr = new InputStreamReader(fis);
// READ STRING OF UNKNOWN LENGTH
StringBuilder sb = new StringBuilder();
char[] inputBuffer = new char[2048];
int l;
// FILL BUFFER WITH DATA
while ((l = isr.read(inputBuffer)) != -1) {
sb.append(inputBuffer, 0, l);
}
// CONVERT BYTES TO STRING
String readString = sb.toString();
Log.i("LOG_TAG", "Read string: " + readString);
// CAN ALSO DELETE THE FILE
deleteFile(fileName);
} catch (IOException e) {
e.printStackTrace();
}
}
}

我们从这里开始通过打开一个文件输入流,并将其传递给一个流阅读器。这将允许我们调用 read() 方法,将数据以字节的形式读取进来,然后我们可以将这些字节追加到一个 StringBuilder 中。一旦完全读取回内容,我们只需从 StringBuilder 返回字符串,瞧!在最后,为了完整性起见,Context 类为你提供了一个简单的删除保存在内部存储中的文件的方法。

外部存储方法

另一方面,外部存储涉及将数据和文件保存到手机的外部安全数字(SD)卡。内部和外部存储背后的概念是相似的,因此让我们首先列举这种存储方式与之前看到的 SharedPreferences 相比的优缺点。在共享偏好设置中,开销要小得多,因此读写简单的 Map 对象比读写磁盘要高效得多。然而,由于你基本上只能使用简单的原始值(大部分情况下;再次强调,最新版本的 Android 允许你保存字符串集合),你实际上是在用灵活性换取效率。使用内部和外部存储机制,不仅可以保存更大的数据块(即整个 XML 文件),还可以保存更复杂的数据形式(即媒体文件、图像文件等)。

那么,内部与外部存储如何选择呢?这两种选择的优缺点要微妙得多。首先,让我们考虑一下存储空间内存)。尽管这取决于用户拥有的手机,但内部存储空间通常可能非常有限,即使是相对较新的手机,内部存储空间也可能低至 512 MB。而外部存储则完全取决于用户手机中的 SD 卡。通常,如果存在 SD 卡,那么外部存储空间可以是内部存储空间的许多倍(这取决于 SD 卡的大小,这可以达到 32 GB 的存储空间)。

现在,让我们考虑一下内部与外部存储的访问速度。不幸的是,在这种情况下,不能得出任何明确的结论,因为读写速度高度依赖于手机使用的内部闪存类型,以及外部存储的 SD 卡的分类。因此,最后要考虑的是每种存储机制的可访问性。再次强调,对于内部存储,数据只能由你的应用程序访问,因此它非常安全,不受潜在恶意的外部应用程序的影响。缺点是,如果应用程序被卸载,那么内部存储空间也会被清除。对于外部存储,其可见性本质上是全球可读和可写的,因此保存的任何文件都会暴露给外部应用程序以及用户。这样就不能保证你的文件会保持安全和未被篡改。

既然我们已经弄清楚了一些差异,让我们回到代码,看看你如何通过以下示例实际访问外部 SD 卡:

public class ExternalStorageExample extends Activity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.main);
String fileName = "my_file.txt";
String msg = "Hello World.";
boolean externalAvailable = false;
boolean externalWriteable = false;
String state = Environment.getExternalStorageState();
if (state.equals(Environment.MEDIA_MOUNTED)) {
// HERE MEDIA IS BOTH AVAILABLE AND WRITEABLE
externalAvailable = true;
externalWriteable = true;
} else if
(state.equals(Environment.MEDIA_MOUNTED_READ_ONLY)) {
// HERE SD CARD IS AVAILABLE BUT NOT WRITEABLE
externalAvailable = true;
} else {
// HERE FAILURE COULD BE RESULT OF MANY SITUATIONS
// NO OP
external storage methodsabout}
if (externalAvailable && externalWriteable) {
// FOR API LEVEL 7 AND BELOW
// RETRIEVE SD CARD DIRECTORY
File r = Environment.getExternalStorageDirectory();
File f = new File(r, fileName);
try {
// NOTE DIFFERENT FROM INTERNAL STORAGE WRITER
FileWriter fWriter = new FileWriter(f);
BufferedWriter out = new BufferedWriter(fWriter);
out.write(msg);
out.close();
} catch (IOException e) {
e.printStackTrace();
}
} else {
Log.e("LOG_TAG", "SD CARD UNAVAILABLE");
}
}
}

为了执行之前的代码,不要忘记在你的清单文件中添加WRITE_EXTERNAL_STORAGE权限。这里,我们从调用Environment类的getExternalStorageState()方法开始,这允许我们检测外部 SD 卡是否实际已挂载且可写。在没有执行这些初步检查的情况下尝试读取或写入文件,将导致抛出错误。

一旦我们知道 SD 卡已挂载并且确实可写,那么对于 API 级别 7 及以下,我们调用getExternalStorageDirectory()来获取到 SD 卡根目录的文件路径。在这一点上,我们只需要创建我们的新文件并实例化一个FileWriterBufferedWriter,然后将我们的字符串写入文件。这里需要注意的是,处理外部存储时写入磁盘的方法与我们之前写入内部存储的磁盘方法不同。

这实际上是一个需要注意和理解的重要点,这就是为什么我如此强调这些写入方法。在内部存储示例中,我们通过调用Context类的openFileOutput()方法获取FileOutputStream对象,该方法以模式作为第二个参数。当传入MODE_PRIVATE时,幕后发生的事情是,每次使用该FileOutStream创建和写入文件时,该文件都会用你的应用程序的唯一 ID(如前所述)进行加密和签名,这样外部应用程序就无法访问这些文件的内容。然而,请记住,在外部存储中创建和写入文件时,默认情况下它们是没有安全强制的,所以任何应用程序(或用户)都可以读取和写入这些文件。这就是为什么你可以使用标准的 Java 方法(例如,FileWriter)来写入外部 SD 卡,但在写入内部存储时则不行。还需要注意的最后一件事是,正如你可以在 Eclipse 的DDMS视图中看到新创建的文件,假设你有 SD 卡设置,你也可以很容易地在DDMS中看到新创建的文本文件:

外部存储方法

因此,在开发你的应用程序时,利用这个DDMS视角,你可以快速地推、拉和监控你写入磁盘的文件。

说到这里,我会快速提及一些在 API 级别 8 之后引入的外部存储写入的变化。这些变化实际上在developer.android.com/reference/android/content/Context.html#getExternalFilesDir(java.lang.String)有很好的文档记录。

但从高层次来看,在 API 级别 8 及以上,我们有两个新的主要方法:

getExternalFilesDir(String type)
getExternalStoragePublicDirectory(String type)

你会注意到,对于这些方法中的每一个,你现在可以传递一个type参数。这些type参数允许你指定你的文件类型,以便它们被组织到正确的子文件夹中。在第一个方法中,返回的外部文件目录根是特定于你的应用程序的,这样当你的应用程序被卸载时,与这些文件相关联的所有文件也会从外部 SD 卡上删除。在第二个方法中,返回的文件目录根是公共的,因此即使你的应用程序被卸载,保存在这些路径上的文件也会保持持久。决定使用哪个方法仅仅取决于你试图保存的文件类型 — 例如,如果它是在你的应用程序中播放的媒体文件,那么如果用户决定卸载你的应用程序,他/她可能不再需要这个文件。

然而,假设你的应用程序允许用户为他们的手机下载壁纸:在这种情况下,你可能会考虑将任何图像文件保存到公共目录中,这样即使用户卸载了你的应用程序,这些文件仍然可以被系统访问。你可以指定的不同type参数有:

DIRECTORY_ALARMS
DIRECTORY_DCIM
DIRECTORY_DOWNLOADS
DIRECTORY_MOVIES
DIRECTORY_MUSIC
DIRECTORY_NOTIFICATIONS
DIRECTORY_PICTURES
DIRECTORY_PODCASTS
DIRECTORY_RINGTONES

因此,我们结束了关于内部和外部存储机制的略显冗长的讨论,并直接进入更厚重的 SQLite 数据库主题。

SQLite 数据库

最后但同样重要的是,迄今为止最复杂且可以说最强大的本地存储方法是使用 SQLite 数据库。每个应用程序都配备了其自己的 SQLite 数据库,该数据库可以被应用程序中的任何类访问,但不能被外部应用程序访问。在深入到复杂的查询或代码片段之前,让我简要概述一下 SQLite 数据库是什么。

SQL(结构化查询语言) 是一种专门为管理关系型数据库中的数据而设计的编程语言。关系型数据库允许你提交插入、删除、更新和获取查询,同时还可以让你创建和修改模式(简单来说就是表格)。SQLite 就是 MySQL、PostgreSQL 和其他流行数据库系统的简化版。它完全自包含且无需服务器,同时仍然支持事务处理,并使用标准的 SQL 语言来执行查询。由于其自包含和可执行的特点,它非常高效、灵活,并且可以被各种编程语言在各种平台上访问(包括我们自己的 Android 平台)。

现在,让我们看看如何实例化一个新的 SQLite 数据库模式,并使用以下代码片段创建一个非常简单的表:

public class SQLiteHelper extends SQLiteOpenHelper {
private static final String DATABASE_NAME = "my_database.db";
// TOGGLE THIS NUMBER FOR UPDATING TABLES AND DATABASE
private static final int DATABASE_VERSION = 1;
// NAME OF TABLE YOU WISH TO CREATE
public static final String TABLE_NAME = "my_table";
// SOME SAMPLE FIELDS
public static final String UID = "_id";
public static final String NAME = "name";
SQLiteHelper(Context context) {
super(context, DATABASE_NAME, null, DATABASE_VERSION);
}
@Override
public void onCreate(SQLiteDatabase db) {
db.execSQL("CREATE TABLE " + TABLE_NAME + " (" + UID + "
INTEGER PRIMARY KEY AUTOINCREMENT," + NAME
+ " VARCHAR(255));");
}
@Override
public void onUpgrade(SQLiteDatabase db, int oldVersion,
int newVersion) {
Log.w("LOG_TAG", "Upgrading database from version " +
oldVersion + " to " + newVersion + ",
which will destroy all old data");
// KILL PREVIOUS TABLE IF UPGRADED
db.execSQL("DROP TABLE IF EXISTS " + TABLE_NAME);
// CREATE NEW INSTANCE OF TABLE
onCreate(db);
}
}

在这里,我们首先会注意到,为了创建一个可定制的数据库架构,我们必须重写SQLiteOpenHelper类。通过重写它,我们可以接着重写onCreate()方法,这将允许我们指定表的结构。在我们的例子中,你会注意到我们只是创建了一个包含两列的表,一个 ID 列和一个 name 列。该查询等价于在 SQL 中运行以下命令:

CREATE TABLE my_table (_id INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR(255));

你还会看到 ID 列被指定为PRIMARY KEY并赋予了AUTOINCREMENT属性——这实际上是针对在 Android 中创建的所有表推荐的,我们将遵循这一标准。最后,你会看到 name 列被声明为字符串类型,最大字符长度为255(对于更长的字符串,我们可以简单地将列类型设置为LONGTEXT)。

重写onCreate()方法之后,我们还重写了onUpgrade()方法。这让我们可以快速简单地改变表的结构。你需要做的是增加DATABASE_VERSION整数值,下次实例化SQLiteHelper时,它将自动调用其onUpgrade()方法,此时我们首先会删除旧版本的数据库,然后创建新版本。

最后,让我们快速地看看如何在我们非常基础且简陋的表中插入和查询值:

public class SQLiteExample extends Activity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.main);
// INIT OUR SQLITE HELPER
SQLiteHelper sqh = new SQLiteHelper(this);
// RETRIEVE A READABLE AND WRITEABLE DATABASE
SQLiteDatabase sqdb = sqh.getWritableDatabase();
// METHOD #1: INSERT USING CONTENTVALUE CLASS
ContentValues cv = new ContentValues();
cv.put(SQLiteHelper.NAME, "jason wei");
// CALL INSERT METHOD
sqdb.insert(SQLiteHelper.TABLE_NAME, SQLiteHelper.NAME,
cv);
// METHOD #2: INSERT USING SQL QUERY
String insertQuery = "INSERT INTO " +
SQLiteHelper.TABLE_NAME +
" (" + SQLiteHelper.NAME + ") VALUES ('jwei')";
sqdb.execSQL(insertQuery);
// METHOD #1: QUERY USING WRAPPER METHOD
Cursor c = sqdb.query(SQLiteHelper.TABLE_NAME,
new String[] { SQLiteHelper.UID, SQLiteHelper.NAME },
null, null, null, null, null);
while (c.moveToNext()) {
// GET COLUMN INDICES + VALUES OF THOSE COLUMNS
int id = c.getInt(c.getColumnIndex(SQLiteHelper.UID));
String name =
c.getString(c.getColumnIndex(SQLiteHelper.NAME));
Log.i("LOG_TAG", "ROW " + id + " HAS NAME " + name);
}
c.close();
// METHOD #2: QUERY USING SQL SELECT QUERY
String query = "SELECT " + SQLiteHelper.UID + ", " +
SQLiteHelper.NAME + " FROM " + SQLiteHelper.TABLE_NAME;
Cursor c2 = sqdb.rawQuery(query, null);
while (c2.moveToNext()) {
int id =
c2.getInt(c2.getColumnIndex(SQLiteHelper.UID));
String name =
c2.getString(c2.getColumnIndex(SQLiteHelper.NAME));
Log.i("LOG_TAG", "ROW " + id + " HAS NAME " + name);
}
c2.close();
// CLOSE DATABASE CONNECTIONS
sqdb.close();
sqh.close();
}
}

请仔细关注这个例子,因为它将为我们接下来的几章内容定下基调。在这个例子中,我们首先实例化我们的SQLiteHelper并获取一个可写的SQLiteDatabase对象。然后我们引入了ContentValues类,这是一个非常方便的包装方法,可以让你快速地在表中插入、更新或删除行。在这里你会注意到,由于我们的 ID 列是使用AUTOINCREMENT字段创建的,我们在插入行时不需要手动分配或增加 ID。因此,我们只需要将非 ID 字段传递给ContentValues对象:在我们的例子中,只需传递 name 列。

之后,我们回到SQLiteDatabase对象,并调用其insert()方法。第一个参数仅仅是数据库名称,第三个参数是我们刚刚创建的ContentValue。第二个参数是唯一一个有点棘手的参数——基本上,如果传递了一个空的ContentValue,因为 SQLite 数据库不能插入空行,所以无论作为第二个参数传递的列是什么,SQLite 数据库都会自动将那个列的值设置为null。通过这样做,我们可以更好地避免抛出 SQLite 异常。

此外,我们可以通过向execSQL()方法传递原始 SQL 查询(如第二个方法所示)来向数据库中插入行。最后,既然我们已经向表中插入了两个行,让我们练习获取和读取这些行。这里我展示了两种方法——第一种是使用SQLiteDatabase帮助方法query(),第二种是执行原始 SQL 查询。在这两种情况下,都会返回一个Cursor对象,您可以将其视为对由您的查询返回的子表行的迭代器:

while (c.moveToNext()) {
// GET COLUMN INDICES + VALUES OF THOSE COLUMNS
int id = c.getInt(c.getColumnIndex(SQLiteHelper.UID));
String name = c.getString(c.getColumnIndex(SQLiteHelper.NAME));
Log.i("LOG_TAG", "ROW " + id + " HAS NAME " + name);
}

一旦我们获得了所需的Cursor,其余部分就非常直接了。因为Cursor的行为类似于迭代器,为了检索每一行,我们需要将其放入一个while循环中,并在每次循环中将游标向下移动一行。然后,在while循环中,我们获取我们想要从中提取数据的列的列索引:在我们的例子中,让我们获取两列,尽管实际上很多时候您只想在特定时间从特定列获取数据。最后,将这些列索引传递给Cursor的正确get()方法——具体来说,如果列的类型是整数,则调用getInt()方法;如果是字符串,则调用getString()方法,依此类推。

但再次强调,我们现在看到的仅仅是通往丰富工具和武器库的基石。很快,我们将会探讨如何编写各种包装方法,以便在开发大型应用程序时简化我们的生活,同时进一步深入挖掘SQLiteDatabase类提供给我们的各种方法和参数。

总结

在第一章中,我们完成了很多工作。我们从最简单、最高效的数据存储方法——SharedPreferences类开始讲起。我们探讨了在应用程序中使用SharedPreferences对象的优缺点,尽管这个类本身仅限于存储基本数据类型,但我们看到了它的使用场景非常丰富。

然后,我们提高了一点复杂性,并研究了内部和外部存储机制。虽然它们不如共享偏好对象直观和高效,但通过利用内部和外部存储,我们能够存储更多的数据以及更复杂的数据(即图片、媒体文件等)。使用内部存储与外部存储的优缺点更为微妙,很多时候它们高度依赖于手机和硬件。但无论如何,这都说明了我早先的观点:掌握 Android 上的数据部分,就是要能够分析每种存储方法的优缺点,并明智地选择最适合应用程序需求的方法。

最后,我们初步探索了 SQLite 数据库,并了解了如何重写SQLiteOpenHelper类以创建自定义的 SQLite 数据库和表。从那里,我们看到了一个示例,演示了如何从Activity类打开和检索 SQLite 数据库,以及如何向表中插入和检索行。由于SQLiteDatabase类的灵活性,我们了解到插入和检索数据有多种方法,这让那些不太熟悉 SQL 的人可以使用包装方法,同时也让那些 SQL 爱好者通过执行原始 SQL 命令来展示他们的查询能力。

在下一章中,我们将重点关注 SQLite 数据库,并尝试构建一个更为复杂但现实的数据库架构。

第二章:使用 SQLite 数据库

之前我们介绍了在 Android 上存储数据的各种方法,从小型简单的原始值到大型的复杂文件类型。在本章中,我们将深入探讨一种极其强大且高效的方式来保存和检索结构化数据:即使用 SQLite 数据库。目前,我们将关注 SQLite 数据库的灵活性和健壮性,将其作为应用程序的本地后端,在后续章节中,我们再关注如何将这个 SQLite 后端与用户界面前端绑定。

创建高级 SQLite 模式

在上一章中,我们通过一个简单的例子了解了如何创建和使用包含两个字段的表:一个整数 ID 字段和一个字符串名称字段。然而,你的应用程序所需的数据库模式通常会比一个表复杂得多。因此,当你突然需要多个表,有些可能还相互依赖时,如何有效地利用SQLiteOpenHelper类来使应用程序的开发保持清晰和直接,同时又不损害模式的健壮性呢?让我们通过一个例子一起来解决这个问题!

考虑一个包含三个表的简单模式:第一个是Students表,包含字段 ID、姓名、状态和年级;第二个是Courses表,包含字段 ID 和名称;第三个是Classes表,包含字段 ID、学生 ID 和课程 ID。我们将尝试创建一个模式,在这个模式中,我们可以添加/移除学生,添加/移除课程,以及注册/退选不同课程的学生。我们可以立即想到的一些挑战如下:

  • 我们如何获得简单的分析,比如每个课程的学生人数?

  • 当我们删除一个还有学生的课程时会发生什么?

  • 当我们移除一个已选课的学生时会发生什么?

话不多说,让我们直接进入代码。我们从定义几个类的模式开始:

public class StudentTable {
// EACH STUDENT HAS UNIQUE ID
public static final String ID = "_id";
// NAME OF THE STUDENT
public static final String NAME = "student_name";
// STATE OF STUDENT'S RESIDENCE
public static final String STATE = "state";
// GRADE IN SCHOOL OF STUDENT
public static final String GRADE = "grade";
// NAME OF THE TABLE
public static final String TABLE_NAME = "students";
}
public class CourseTable {
// UNIQUE ID OF THE COURSE
public static final String ID = "_id";
// NAME OF THE COURSE
public static final String NAME = "course_name";
// NAME OF THE TABLE
public static final String TABLE_NAME = "courses";
}
// THIS ESSENTIALLY REPRESENTS A MAPPING FROM STUDENTS TO COURSES
public class ClassTable {
// UNIQUE ID OF EACH ROW - NO REAL MEANING HERE
public static final String ID = "_id";
// THE ID OF THE STUDENT
public static final String STUDENT_ID = "student_id";
// THE ID OF ASSOCIATED COURSE
public static final String COURSE_ID = "course_id";
// THE NAME OF THE TABLE
public static final String TABLE_NAME = "classes";
}

下面是创建数据库模式的代码(这应该和我们之前看到的非常相似):

public class SchemaHelper extends SQLiteOpenHelper {
private static final String DATABASE_NAME = "adv_data.db";
// TOGGLE THIS NUMBER FOR UPDATING TABLES AND DATABASE
private static final int DATABASE_VERSION = 1;
SchemaHelper(Context context) {
super(context, DATABASE_NAME, null, DATABASE_VERSION);
}
@Override
public void onCreate(SQLiteDatabase db) {
// CREATE STUDENTS TABLE
db.execSQL("CREATE TABLE " + StudentTable.TABLE_NAME
+ " (" + StudentTable.ID + " INTEGER PRIMARY KEY AUTOINCREMENT,"
+ StudentTable.NAME + " TEXT,"
+ StudentTable.STATE + " TEXT,"
+ StudentTable.GRADE + " INTEGER);");
// CREATE COURSES TABLE
db.execSQL("CREATE TABLE " + CourseTable.TABLE_NAME + " (" + CourseTable.ID + " INTEGER PRIMARY KEY AUTOINCREMENT,"
+ CourseTable.NAME + " TEXT);");
// CREATE CLASSES MAPPING TABLE
db.execSQL("CREATE TABLE " + ClassTable.TABLE_NAME + " (" + ClassTable.ID + " INTEGER PRIMARY KEY AUTOINCREMENT,"
+ ClassTable.STUDENT_ID + " INTEGER,"
+ ClassTable.COURSE_ID + " INTEGER);");
}
@Override
public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
Log.w("LOG_TAG", "Upgrading database from version " + oldVersion + " to " + newVersion + ", which will destroy all old data");
// KILL PREVIOUS TABLES IF UPGRADED
db.execSQL("DROP TABLE IF EXISTS " + StudentTable.TABLE_NAME);
db.execSQL("DROP TABLE IF EXISTS " + CourseTable.TABLE_NAME);
db.execSQL("DROP TABLE IF EXISTS " + ClassTable.TABLE_NAME);
// CREATE NEW INSTANCE OF SCHEMA
onCreate(db);
}
}

所以在这里我们看到,在我们的onCreate()方法中,我们执行 SQL 命令来创建所有三个表,而且,在onUpgrade()方法中,我们执行 SQL 命令来删除所有三个表,随后重新创建所有三个表。当然,由于我们重写了SQLiteOpenHelper类,理论上我们可以按照自己的方式定制这些方法的行为(例如,一些开发者可能不希望在onUpgrade()方法中删除整个模式),但现在让我们保持功能简单。

在这一点上,对于那些精通 SQL 编程和数据库模式的读者,您可能想知道是否可以向 SQLite 数据库模式中添加触发器和键约束。答案是:“是的,您可以使用触发器,但不行,您不能使用外键约束。”无论如何,花时间编写和实施触发器将偏离本书的核心内容,因此我选择省略这一讨论(尽管这些在我们的简单示例中也可能非常有帮助)。

现在我们已经创建好了模式,在开始设计各种复杂查询以提取不同的数据组之前(我们将在下一章看到这些内容),是时候编写一些包装方法了。这将帮助我们解决之前提到的一些问题,最终帮助我们创建一个健壮的数据库。

为您的 SQLite 数据库提供包装方法

所以现在我们面前有一个相当复杂的模式,之前我们提到过,如果我们移除一个已选课的学生会发生什么,反之,如果我们删除一个有多个学生选课的课程会发生什么?显然,我们不想出现任何一种情况——在第一种情况下,我们会有充满已不再就读于大学的学生课程,而在第二种情况下,我们会遇到学生参加已经不再提供的课程!

因此,是时候实施一些这些规则了,我们将通过向我们的SchemaHelper类添加一些方便的方法来实现这一点。同样,这些规则可以通过使用触发语句来实施(记住,Android 的 SQLite 数据库不支持键约束),但使用包装方法的好处在于,它们对于可能刚接触应用程序代码库的开发人员来说更加直观。通过使用包装类,开发人员可以安全地与可能对其模式知之甚少的数据库进行交互。现在,让我们从简单的包装方法开始:

public class SchemaHelper extends SQLiteOpenHelper {
private static final String DATABASE_NAME = "adv_data.db";
// TOGGLE THIS NUMBER FOR UPDATING TABLES AND DATABASE
private static final int DATABASE_VERSION = 1;
SchemaHelper(Context context) {
super(context, DATABASE_NAME, null, DATABASE_VERSION);
}
@Override
public void onCreate(SQLiteDatabase db) {
...
}
@Override
public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
...
}
// WRAPPER METHOD FOR ADDING A STUDENT
public long addStudent(String name, String state, int grade) {
// CREATE A CONTENTVALUE OBJECT
ContentValues cv = new ContentValues();
cv.put(StudentTable.NAME, name);
cv.put(StudentTable.STATE, state);
cv.put(StudentTable.GRADE, grade);
// RETRIEVE WRITEABLE DATABASE AND INSERT
SQLiteDatabase sd = getWritableDatabase();
long result = sd.insert(StudentTable.TABLE_NAME, StudentTable.NAME, cv);
return result;
}
// WRAPPER METHOD FOR ADDING A COURSE
public long addCourse(String name) {
ContentValues cv = new ContentValues();
cv.put(CourseTable.NAME, name);
SQLiteDatabase sd = getWritableDatabase();
long result = sd.insert(CourseTable.TABLE_NAME, CourseTable.NAME, cv);
return result;
}
// WRAPPER METHOD FOR ENROLLING A STUDENT INTO A COURSE
public boolean enrollStudentClass(int studentId, int courseId) {
ContentValues cv = new ContentValues();
cv.put(ClassTable.STUDENT_ID, studentId);
cv.put(ClassTable.COURSE_ID, courseId);
SQLiteDatabase sd = getWritableDatabase();
long result = sd.insert(ClassTable.TABLE_NAME, ClassTable.STUDENT_ID, cv);
return (result >= 0);
}
}

现在我们有了三个向模式中添加数据的简单包装方法。前两个涉及向数据库中添加新学生和新课程,最后一个涉及在学生(由他的/她的 ID 表示)和课程之间添加新的映射(本质上,我们是通过这个映射将学生注册到课程中)。注意,在每个包装方法中,我们只是将值添加到ContentValue对象中,获取可写的 SQLite 数据库,然后将这个ContentValue作为新行插入到指定的表中。接下来,让我们编写一些用于检索数据的包装方法:

public class SchemaHelper extends SQLiteOpenHelper {
public long addStudent(String name, String state, int grade) {
}
public long addCourse(String name) {
}
public boolean enrollStudentClass(int studentId, int courseId) {
}
// GET ALL STUDENTS IN A COURSE
public Cursor getStudentsForCourse(int courseId) {
SQLiteDatabase sd = getWritableDatabase();
// WE ONLY NEED TO RETURN STUDENT IDS
String[] cols = new String[] { ClassTable.STUDENT_ID };
String[] selectionArgs = new String[] { String.valueOf(courseId) };
// QUERY CLASS MAP FOR STUDENTS IN COURSE
Cursor c = sd.query(ClassTable.TABLE_NAME, cols, ClassTable.COURSE_ID + "= ?", selectionArgs, null, null, null);
return c;
}
// GET ALL COURSES FOR A GIVEN STUDENT
public Cursor getCoursesForStudent(int studentId) {
SQLiteDatabase sd = getWritableDatabase();
// WE ONLY NEED TO RETURN COURSE IDS
String[] cols = new String[] { ClassTable.COURSE_ID };
String[] selectionArgs = new String[] { String.valueOf(studentId) };
Cursor c = sd.query(ClassTable.TABLE_NAME, cols, ClassTable.STUDENT_ID + "= ?", selectionArgs, null, null, null);
return c;
}
public Set<Integer> getStudentsByGradeForCourse(int courseId, int grade) {
SQLiteDatabase sd = getWritableDatabase();
// WE ONLY NEED TO RETURN COURSE IDS
String[] cols = new String[] { ClassTable.STUDENT_ID };
String[] selectionArgs = new String[] { String.valueOf(courseId) };
// QUERY CLASS MAP FOR STUDENTS IN COURSE
Cursor c = sd.query(ClassTable.TABLE_NAME, cols, ClassTable.COURSE_ID + "= ?", selectionArgs, null, null, null);
Set<Integer> returnIds = new HashSet<Integer>();
while (c.moveToNext()) {
int id = c.getInt(c.getColumnIndex
(ClassTable.STUDENT_ID));
returnIds.add(id);
}
// MAKE SECOND QUERY
cols = new String[] { StudentTable.ID };
selectionArgs = new String[] { String.valueOf(grade) };
c = sd.query(StudentTable.TABLE_NAME, columns, StudentTable.GRADE + "= ?", selectionArgs, null, null, null);
Set<Integer> gradeIds = new HashSet<Integer>();
while (c.moveToNext()) {
int id = c.getInt(c.getColumnIndex(StudentTable.ID));
gradeIds.add(id);
}
// RETURN INTERSECTION OF ID SETS
returnIds.retainAll(gradeIds);
return returnIds;
}
}

在这里,我们有三个相当类似的方法,它们允许我们从模式中获得非常实用的数据集:

  • 能够获取给定课程的学生列表

  • 能够获取给定学生的课程列表

  • 最后(为了增加一些复杂性),能够获取给定课程特定年级的学生列表

请注意,在所有三种方法中,我们开始尝试SQLiteDatabase对象的query()方法中的一些参数,所以现在似乎是仔细看看这些参数是什么以及我们之前到底做了什么的好时机:

public Cursor query(String table, String[] columns, String selection, String[] selectionArgs, String groupBy, String having, String orderBy)

另一种方式:

public Cursor query(String table, String[] columns, String selection, String[] selectionArgs, String groupBy, String having, String orderBy, String limit)
public Cursor query(boolean distinct, String table, String[] columns, String selection, String[] selectionArgs, String groupBy, String having, String orderBy, String limit)

为了简单起见,以下是调用上一个方法的方式:

Cursor c = sd.query(ClassTable.TABLE_NAME, cols, ClassTable.COURSE_ID + "= ?", selectionArgs, null, null, null);

所以这里快速解释一下这三种方法。第一个query()方法是标准方法,你首先在第一个参数中指定表,然后在第二个参数中指定要返回的列。这相当于在标准 SQL 中执行一个SELECT语句。然后,在第三个参数中我们开始过滤我们的查询,这些过滤器的语法等同于在SELECT查询的末尾包含一个WHERE子句。在我们的示例中,我们只要求返回包含学生 ID 的列,因为这是我们唯一关心的列(由于我们按课程 ID 列进行过滤,因此返回此列将是不必要的重复)。然后,在过滤参数中,我们要求按课程 ID 进行过滤,其语法等同于传入以下字符串:

WHERE course_id = ?

在这里,问号充当占位符,代表我们将要传递到过滤器的任何值。换句话说,WHERE语句的格式已经存在,但我们只需要将实际的过滤值替换到问号中。在这种情况下,我们将给定的课程 ID 传递到第四个参数中。

最后三个参数(groupByhavingorderBy)对于熟悉 SQL 的人来说应该很有意义,但对于那些不熟悉的人,以下是每个参数的快速解释:

  • groupBy - 添加这个功能可以让你按照指定的列对结果进行分组。如果你需要获取例如课程 ID 和该课程学生数量的表格,这个功能将非常有用:只需在Class表中按课程 ID 分组即可实现这一点。

  • having - 与groupBy子句结合使用,这个子句允许你过滤聚合后的结果。假设你在Class表中按课程 ID 分组,并希望过滤掉所有注册学生少于 10 人的班级,你可以使用having子句来实现这一点。

  • orderBy - 这是一个相当直观的子句,orderBy子句允许我们按指定列(们)以及升序或降序对查询结果子表进行排序。例如,假设你想按成绩和姓名对Students表进行排序——指定一个orderBy子句将允许你这样做。

最后,在两个query()变体中,你会看到增加了limitdistinct参数:limit参数允许你限制返回的行数,而distinct布尔值允许你指定是否只返回唯一的行。如果这些对你来说还是不太明白,不用害怕——我们将在下一章重点介绍构建复杂查询。

既然我们已经理解了query()方法的工作原理,让我们回顾一下之前的例子,并详细阐述getStudentsByGradeForCourse()方法。尽管执行这个方法有很多种方式,但从概念上讲它们都非常相似:首先,我们查询给定课程的所有学生,然后在这些学生中筛选并只保留指定年级的学生。我的实现方式是首先从给定课程获取所有学生 ID 的集合,然后获取给定年级的所有学生的集合,并简单地返回这两个集合的交集。至于这是否是最优实现,完全取决于你的数据库大小。

现在,最后但同样重要的是,让我们通过一些特殊的移除包装方法来加强之前提到的移除规则:

public class SchemaHelper extends SQLiteOpenHelper {
public Cursor getStudentsForCourse(int courseId) {
...
}
public Cursor getCoursesForStudent(int studentId) {
...
}
public Set<Integer> getStudentsAndGradeForCourse(int courseId, int grade) {
...
}
// METHOD FOR SAFELY REMOVING A STUDENT
public boolean removeStudent(int studentId) {
SQLiteDatabase sd = getWritableDatabase();
String[] whereArgs = new String[] { String.valueOf(studentId) };
// DELETE ALL CLASS MAPPINGS STUDENT IS SIGNED UP FOR
sd.delete(ClassTable.TABLE_NAME, ClassTable.STUDENT_ID + "= ? ", whereArgs);
// THEN DELETE STUDENT
int result = sd.delete(StudentTable.TABLE_NAME, StudentTable.ID + "= ? ", whereArgs);
return (result > 0);
}
// METHOD FOR SAFELY REMOVING A STUDENT
public boolean removeCourse(int courseId) {
SQLiteDatabase sd = getWritableDatabase();
String[] whereArgs = new String[] { String.valueOf(courseId) };
// MAKE SURE YOU REMOVE COURSE FROM ALL STUDENTS ENROLLED
sd.delete(ClassTable.TABLE_NAME, ClassTable.COURSE_ID + "= ? ", whereArgs);
// THEN DELETE COURSE
int result = sd.delete(CourseTable.TABLE_NAME, CourseTable.ID + "= ? ", whereArgs);
return (result > 0);
}
}

在这里,我们有两个移除方法,在每一个方法中,我们通过阻止有人在从Class映射表中先移除这些课程之前取消课程,以及反之亦然,手动实施一些模式规则。我们调用SQLiteDatabase类的delete()方法,这个方法与query()方法类似,允许你传入表名,指定一个过滤参数(即一个WHERE子句),然后允许你传入这些过滤器的值(注意,在delete()query()方法中,你可以指定多个过滤器,但关于这一点稍后会详细介绍)。

最后,让我们将这些方法付诸实践,并实现一个Activity类:

public class SchemaActivity extends Activity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.main);
SchemaHelper sh = new SchemaHelper(this);
// ADD STUDENTS AND RETURN THEIR IDS
long sid1 = sh.addStudent("Jason Wei", "IL", 12);
long sid2 = sh.addStudent("Du Chung", "AR", 12);
long sid3 = sh.addStudent("George Tang", "CA", 11);
long sid4 = sh.addStudent("Mark Bocanegra", "CA", 11);
long sid5 = sh.addStudent("Bobby Wei", "IL", 12);
// ADD COURSES AND RETURN THEIR IDS
long cid1 = sh.addCourse("Math51");
long cid2 = sh.addCourse("CS106A");
long cid3 = sh.addCourse("Econ1A");
// ENROLL STUDENTS IN CLASSES
sh.enrollStudentClass((int) sid1, (int) cid1);
sh.enrollStudentClass((int) sid1, (int) cid2);
sh.enrollStudentClass((int) sid2, (int) cid2);
sh.enrollStudentClass((int) sid3, (int) cid1);
sh.enrollStudentClass((int) sid3, (int) cid2);
sh.enrollStudentClass((int) sid4, (int) cid3);
sh.enrollStudentClass((int) sid5, (int) cid2);
// GET STUDENTS FOR COURSE
Cursor c = sh.getStudentsForCourse((int) cid2);
while (c.moveToNext()) {
int colid = c.getColumnIndex(ClassTable.STUDENT_ID);
int sid = c.getInt(colid);
System.out.println("STUDENT " + sid + " IS ENROLLED IN COURSE " + cid2);
}
// GET STUDENTS FOR COURSE AND FILTER BY GRADE
Set<Integer> sids = sh.getStudentsByGradeForCourse ((int) cid2, 11);
for (Integer sid : sids) {
System.out.println("STUDENT " + sid + " OF GRADE 11 IS ENROLLED IN COURSE " + cid2);
}
}
}

首先,我们在模式中添加一些虚拟数据;在我这个案例中,我会添加五个学生和三门课程,然后将这些学生报名到一些课程中。当模式中有了数据后,我会尝试一些方法,并首先请求所有报名 CS106A 的学生。之后,我会测试我们编写的另一个包装方法,并请求所有 11 年级报名 CS106A 的学生。以下是运行这个Activity的输出结果:

为您的 SQLite 数据库提供包装器

看吧!我们很快发现学生 1、2、3 和 5 都报名了 CS106A。然而,在按 11 年级筛选后,我们发现只有学生 3 在 11 年级报名了 CS106A —— 可怜的乔治。现在让我们测试一下移除方法:

public class SchemaActivity extends Activity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.main);
SchemaHelper sh = new SchemaHelper(this);
long sid1 = sh.addStudent("Jason Wei", "IL", 12);
// GET CLASSES I'M TAKING
c = sh.getCoursesForStudent((int) sid1);
while (c.moveToNext()) {
int colid = c.getColumnIndex(ClassTable.COURSE_ID);
int cid = c.getInt(colid);
System.out.println("STUDENT " + sid1 + " IS ENROLLED IN COURSE " + cid);
}
// TRY REMOVING A COURSE
sh.removeCourse((int) cid1);
System.out.println("------------------------------");
// SEE IF REMOVAL KEPT SCHEMA CONSISTENT
c = sh.getCoursesForStudent((int) sid1);
while (c.moveToNext()) {
int colid = c.getColumnIndex(ClassTable.COURSE_ID);
int cid = c.getInt(colid);
System.out.println("STUDENT " + sid1 + " IS ENROLLED IN COURSE " + cid);
}
}
}

这一次,我们首先查询学生 1(我自己)所报名的所有课程。但是哎呀!这个季度数学 51 出了点问题,所以被取消了!我们删除这门课程,并再次请求查看学生 1 所——报名的所有课程,期待看到数学 51 已经从列表中移除。输出结果如下:

为您的 SQLite 数据库提供包装器

事实上,我们可以看到一开始我同时选了 Math51 和 CS106A,但课程移除后,我不仅仅只选了 CS106A!通过对这些常见的插入、获取和删除函数进行封装,我们既可以简化未来的开发工作,同时也可以强制实施某些架构规则。

最后,让我们通过介绍如何连接到 SQLite 终端以表格形式查看你的数据以及发送 SQLite 查询来结束本章内容——这在调试你的应用程序并确保你的数据被正确添加/更新/删除时非常有用。

调试你的 SQLite 数据库

Android 平台为你提供了一个非常强大的调试工具,名为Android Debug Bridge (adb)。adb shell 是一个多功能的命令行接口,可以让你与正在运行的模拟器或连接的 Android 设备进行通信。adb 工具可以在你的 SDK 的/platform-tools 目录中找到,一旦启动,它就能做到从安装应用程序,到从模拟器推送和拉取数据,再到连接到 sqlite3 数据库并发送查询(更多详情请见开发者文档developer.android.com/guide/developing/tools/adb.html)。

要使用 adb,只需打开你的终端,导航到 /<你的-sdk-目录>/platform-tools/ 并输入以下命令:

adb shell

如果你想要连接到特定的模拟器,可以输入以下命令:

adb –s emulator-xxxx shell

在此阶段,你应该已经启动了 adb 工具,这时你需要告诉它连接到模拟器的 sqlite3 数据库。可以通过发出 sqlite3 命令,然后传递到你的应用程序的数据库文件路径,如下所示:

# sqlite3 /data/data/<your-package-path>/databases/<your-database>.db

在我们的例子中,命令如下所示:

# sqlite3 /data/data/jwei.apps.dataforandroid/databases/adv_data.db

在这个阶段,我们应该能够发出各种 SQL 查询,从查看数据库架构到更新和删除我们任何表中的单个数据行。以下是一些你可能觉得最有用的示例命令:

  • .tables 显示你数据库中的所有表

  • .output FILENAME 允许你将查询结果输出到文件中(例如,用于进一步分析)

  • .mode MODE 允许你指定输出文件格式(即 CSV、HTML 等,对于电子表格类型的分析可能很有用)

  • SELECT * FROM table_name 是选择给定表所有列的标准查询(这相当于表的行执行 get() 命令)

  • SELECT * FROM table_name WHERE col = 'value' 是选择给定表的所有列但带有列筛选的标准查询

  • SELECT col1, col2 FROM table_name 是选择给定表特定列的标准查询

下面是我们使用之前架构中的一些命令的例子:

调试你的 SQLite 数据库

希望这能帮助您开始,但若要获取 sqlite3 命令的完整列表,请查看www.sqlite.org/sqlite.html,若要获取更详尽的复杂查询列表,请稍等片刻——接下来将会介绍。

本章概要

在本章中,我们从仅包含一个表的超级基础数据库架构,发展到了包含多个相互依赖的表的完整架构。我们首先了解了如何通过重写SQLiteOpenHelper类来创建和升级多个表,然后考虑了围绕具有相互依赖关系的数据库架构的一些挑战。我们决定通过围绕我们的数据库架构及其表创建一系列包装方法来应对这些挑战,这些方法旨在便于未来的开发,同时也确保未来数据的健壮性。这些包装方法包括从简单的添加方法(因为我们能够隐藏不断请求可写SQLiteDatabase的需求),到更复杂的删除方法,隐藏了实施各种架构规则所需的所有功能。

然后,我们实际实现了一个Activity类来展示我们的新数据库架构,并运行了一些示例数据库命令以测试其功能。尽管我们能够验证和输出所有命令的结果,但我们意识到这对于调试我们的 sqlite3 数据库来说相当冗长且不是最佳方式,因此我们研究了 Android 调试桥(adb)工具。通过 adb 工具,我们能够打开一个命令行终端,进而连接到正在运行的模拟器或 Android 设备实例,随后连接到该模拟器/设备的 sqlite3 数据库。在这里,我们可以通过发出各种 SQL 命令和查询,以非常自然的方式与 sqlite3 数据库进行交互。

现在,我们迄今为止所见到的查询相当基础,但在必要时,它们将满足大多数应用程序开发的需求。然而,我们将在下一章看到,通过掌握更高级的 SQL 查询概念,我们可以在应用程序中获得显著的性能提升和内存提升!

第三章:SQLite 查询

在上一章中,我们将数据库构建提升了一个层次——将仅涉及一个孤表的简单模式转变为涉及三个相互依赖的复杂模式。现在我们已经为在 Android 上开发自定义 SQLite 数据库打下了坚实的基础,是时候锦上添花了。

理论上,我们可以有一个通用的get()查询,它将数据库中的所有列的每一行作为Cursor对象返回给我们,然后过滤和处理每一行以获取我们想要的数据——我们可以做得更好。不要误会我的意思——Java 很快——但是当涉及到在相对有限的内存中处理可能成千上万行数据时,为什么不优化事物并让 SQL 发挥其最大的作用——那就是查询事物!

在下一章中,我们将重点介绍在 Android 客户端端(即使用 Java 接口)解析和过滤数据与在 SQLite 数据库本身构建更高级的 SQL 查询和解析/过滤数据之间找到正确的平衡。

构建 SQLite 查询的方法

首先,让我们确定构建查询的不同方式。正如我们之前看到的,查询 SQLite 数据库最低级的方法是通过SQLiteDatabase类的rawQuery()方法,定义如下:

Cursor rawQuery(String sql, String[] selectionArgs)

这个方法主要是为那些在 SQL 方面有扎实背景的人准备的,因为您可以直接将 SQL 查询作为第一个参数传递给方法。如果您的 SQL 查询涉及到任何WHERE过滤,那么第二个参数允许您传入这些过滤值(我们很快将看到几个使用这个的例子)。

SQLiteDatabase类提供的第二种查询方法是提交查询的便捷包装——使用query()方法(我们之前也见过),任何实际的 SQL 编程都被隐藏起来,取而代之的是将查询的所有部分作为参数传递:

Cursor query(String table, String[] columns, String selection, String[] selectionArgs, String groupBy, String having, String orderBy)

使用包含distinctlimit约束参数的替代query()方法。同样,前面的参数应该相对容易理解,但所有这些方法在看到一个给定查询时一起看会更有意义。但是,在继续这些示例之前,让我们先看看构建 SQL 查询的第三种方法。

这第三种方法是我们尚未见过,它来自SQLiteQueryBuilder类。由于不需要提交原始 SQL 查询,或者处理便捷方法,这对于那些完全不了解 SQL 的新手来说可能仍然显得有些吓人,因此 Android 平台决定提供一个完整的便捷类,以帮助开发者尽可能无缝地与 SQLite 数据库交互。尽管这个类有许多与之相关的方法(我邀请您在线浏览开发者文档以获取更多详细信息),以下是我们将在本章中重点介绍的一些更重要方法:

String buildQuery(String[] projectionIn, String selection, String groupBy, String having, String sortOrder, String limit)

前面的方法是为了构建SELECT语句提供方便,可以用于一组通过UNION操作在buildUnionQuery()方法中连接的SELECT语句:

String buildUnionQuery(String[] subQueries, String sortOrder, String limit)

以下是一种允许你传递一组SELECT语句(可能使用buildQuery()便利方法构建)的方法,并构建一个将返回这些子查询的UNION的查询:

String buildQueryString(boolean distinct, String tables, String[] columns, String where, String groupBy, String having, String orderBy, String limit)

使用给定参数构建 SQL 查询,类似于SQLiteDatabase类的query()方法,但只是将查询作为字符串返回:

Void setDistinct(boolean distinct)

上面的操作允许你将当前查询设置为仅DISTINCT行。

Void setTables(String inTables)

允许你设置要查询的表格列表,如果传递了多个表格,则可以对这些表格执行JOIN操作。

既然我们已经了解了所有可用的不同方法,让我们探索一些基本的 SQLite 查询,看看如何使用前面描述的每种方法来执行相对简单的查询!

SELECT语句

使用我们来自第二章,使用 SQLite 数据库Students架构,让我们先看看此时我们的Students表的样子:

Id 姓名 年级
1 魏杰森 伊利诺伊州 12
2 杜钟 阿肯色州 12
3 乔治·唐 加利福尼亚州 11
4 马克·博卡内格拉 加利福尼亚州 11
5 魏波比 伊利诺伊州 12

这样,对于我们要进行的每个查询,我们都会确切地知道应该期望什么结果,因此我们可以验证我们的查询。在我们深入探讨之前,以下是本节将涵盖的内容列表:

  • SELECT语句

  • 带有列规范的SELECT语句

  • WHERE筛选条件

  • AND/OR操作符

  • DISTINCT子句

  • LIMIT子句

一次要掌握的内容很多,尤其是对于那些没有 SQL 经验的人来说,但一旦你学会了这些基本构建块,你将能够构建更长、更复杂的查询。那么,让我们从最基本的SELECT查询开始:

public class BasicQueryActivity extends Activity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.main);
/*
* SELECT Query
*/
System.out.println("METHOD 1");
// METHOD #1 - SQLITEDATABASE RAWQUERY()
Cursor c = sqdb.rawQuery("SELECT * from " + StudentTable.TABLE_NAME, null);
while (c.moveToNext()) {
int colid = c.getColumnIndex(StudentTable.NAME);
String name = c.getString(colid);
System.out.println("GOT STUDENT " + name);
}
SELECT statementsaboutSystem.out.println("METHOD 2");
// METHOD #2 - SQLITEDATABASE QUERY()
c = sqdb.query(StudentTable.TABLE_NAME, null, null, null, null, null, null);
while (c.moveToNext()) {
int colid = c.getColumnIndex(StudentTable.NAME);
String name = c.getString(colid);
System.out.println("GOT STUDENT " + name);
}
System.out.println("METHOD 3");
// METHOD #3 - SQLITEQUERYBUILDER
String query = SQLiteQueryBuilder.buildQueryString (false, StudentTable.TABLE_NAME, null, null, null, null, null, null);
System.out.println(query);
c = sqdb.rawQuery(query, null);
while (c.moveToNext()) {
int colid = c.getColumnIndex(StudentTable.NAME);
String name = c.getString(colid);
System.out.println("GOT STUDENT " + name);
}
}
}

在这里,我们看到在第一个方法中,我们只是传递标准的 SQL 查询,而在第二个方法中我们将查询分解为其不同的参数(即表名、选择筛选器等)。最后,在最后一个方法中,我们注意到它看起来与第二个方法非常相似(目前是这样),我们再次将查询分解为其不同的参数,但不是返回Cursor,我们的方法将查询作为字符串返回,然后我们可以将其作为原始查询执行。这样做的原因是 SQLiteQueryBuilder 的一个优点是你可以指定多个查询并一次性提交它们,有效地执行UNION SQL 查询 - 但我们稍后会玩转这个功能。

现在,让我们看看这些查询的结果,并验证这些结果是否正确:

SELECT 语句

在我看来效果很不错!我们可以看到,每种方法都能如预期那样返回我们表格的所有行。在第三种方法下,我们还可以看到使用我们的SQLiteQueryBuilder类构建的查询,并且可以确认,在第一种方法中提交的 SQL 查询与第三种方法中构建的查询完全匹配。

现在,假设你有一个包含成千上万行数据、几十列的大型表格——为了效率和内存考虑,实践中通常建议不要用查询返回整个表格,而是应该细化查询,只返回那些感兴趣的数据列!那么,让我们看看如何在SELECT查询中指定要返回哪些列:

/*
* SELECT COLUMNS Query
*/
System.out.println("METHOD 1");
// METHOD #1 - SQLITEDATABASE RAWQUERY()
c = sqdb.rawQuery(
"SELECT " + StudentTable.NAME + "," + StudentTable.STATE + " from " + StudentTable.TABLE_NAME, null);
while (c.moveToNext()) {
int colid = c.getColumnIndex(StudentTable.NAME);
int colid2 = c.getColumnIndex(StudentTable.STATE);
}
System.out.println("METHOD 2");
// METHOD #2 - SQLITEDATABASE QUERY()
String[] cols = new String[] { StudentTable.NAME, StudentTable.STATE };
c = sqdb.query(StudentTable.TABLE_NAME, cols, null, null, null, null, null);
SELECT statementsresults, validatingwhile (c.moveToNext()) {
SELECT statementsaboutint colid = c.getColumnIndex(StudentTable.NAME);
int colid2 = c.getColumnIndex(StudentTable.STATE);
}
System.out.println("METHOD 3");
// METHOD #3 - SQLITEQUERYBUILDER
query = SQLiteQueryBuilder.buildQueryString(false, StudentTable.TABLE_NAME, cols, null, null, null, null, null);
System.out.println(query);
c = sqdb.rawQuery(query, null);
while (c.moveToNext()) {
int colid = c.getColumnIndex(StudentTable.NAME);
int colid2 = c.getColumnIndex(StudentTable.STATE);
}

我们可以看到,所有三种方法的查询整体结构是相同的,但在第二种和第三种方法中,我们传递一个包含我们想要的数据列的String[]。再次,为了验证我们的查询是否按预期工作,以下是这些查询的输出:

语句

我们可以看到,确实能够返回每个学生及其各自的状态。最后再次注意,第三种方法中构建的查询与传递给第一种方法的原始 SQL 查询是相同的——它们应该完全匹配,实际上也确实如此。

WHERE筛选器和 SQL 运算符

通常,能够根据列的值来过滤数据是非常重要的!这正是WHERE筛选器派上用场的地方,作为数据库开发者,你将经常使用这些WHERE筛选器。就此而言,让我们看看这些WHERE筛选器(在 Android 中也被称为选择参数)是如何通过我们的三种查询构建方法实现的:

/*
* WHERE Filter - Filter by State
*/
System.out.println("METHOD 1");
// METHOD #1 - SQLITEDATABASE RAWQUERY()
c = sqdb.rawQuery("SELECT * from " + StudentTable.TABLE_NAME + " WHERE " + StudentTable.STATE + "= ? ", new String[] { "IL" });
while (c.moveToNext()) {
int colid = c.getColumnIndex(StudentTable.NAME);
int colid2 = c.getColumnIndex(StudentTable.STATE);
}
System.out.println("METHOD 2");
// METHOD #2 - SQLITEDATABASE QUERY()
c = sqdb.query(StudentTable.TABLE_NAME, null, StudentTable.STATE + "= ? ", new String[] { "IL" }, null, null, null);
while (c.moveToNext()) {
int colid = c.getColumnIndex(StudentTable.NAME);
int colid2 = c.getColumnIndex(StudentTable.STATE);
}
System.out.println("METHOD 3");
// METHOD #3 - SQLITEQUERYBUILDER
query = SQLiteQueryBuilder.buildQueryString(false, StudentTable.TABLE_NAME, null, StudentTable.STATE + "='IL'", null, null, null, null);
System.out.println(query);
c = sqdb.rawQuery(query, null);
while (c.moveToNext()) {
int colid = c.getColumnIndex(StudentTable.NAME);
int colid2 = c.getColumnIndex(StudentTable.STATE);
}

在第一种方法中,我们可以看到一个标准的 SQL WHERE子句是如何格式化的。知道了这一点,通过我们的第二和第三种方法,我们可以看到,只需将一个类似于WHERE子句格式的字符串传递给选择参数(WHERE本身省略,因为这会自动附加到你的查询中)。这可以在第三种方法中由我们的SQLiteQueryBuilder类返回的构建查询中明确看到:

筛选器和 SQL 运算符

与任何编程语言一样,你可以通过使用AND/OR运算符来实现过滤逻辑;这对 SQL 同样适用,特别是对于 SQL WHERE筛选器。你可以编写不仅满足一组条件的所有行的查询,也可以编写满足所有给定条件或宽松地说,仅满足多个给定条件之一的行的查询。以下是一个例子,我们不仅返回伊利诺伊州的学生,还利用 SQL OR运算符,也查询阿肯色州的学生:

/*
* AND/OR Clauses
*/
System.out.println("METHOD 1");
// METHOD #1 - SQLITEDATABASE RAWQUERY()
c = sqdb.rawQuery("SELECT * from " + StudentTable.TABLE_NAME + " WHERE " + StudentTable.STATE + "= ? OR " + StudentTable.STATE + "= ?", new String[] { "IL", "AR" });
System.out.println("METHOD 2");
// METHOD #2 - SQLITEDATABASE QUERY()
c = sqdb.query(StudentTable.TABLE_NAME, null, StudentTable.STATE + "= ? OR " + StudentTable.STATE + "= ?", new String[] { "IL", "AR" }, null, null, null);
System.out.println("METHOD 3");
// METHOD #3 - SQLITEQUERYBUILDER
query = SQLiteQueryBuilder.buildQueryString(false, StudentTable.TABLE_NAME, null, StudentTable.STATE + "='IL' OR " + StudentTable.STATE + "='AR'", null, null, null, null);
System.out.println(query);
c = sqdb.rawQuery(query, null);

启动电源,让我们来看看 SQL 中的DISTINCT子句:

WHERE 筛选器和 SQL 操作符

LIMIT子句仅仅允许您限制要返回的行数。LIMIT有两种格式:

DISTINCTLIMIT子句

因此,我们的朋友 Du 已经出现在结果集中!

/*
* DISTINCT Clause
*/
System.out.println("METHOD 1");
// METHOD #1 - SQLITEDATABASE RAWQUERY()
c = sqdb.rawQuery("SELECT DISTINCT " + StudentTable.STATE + " from " + StudentTable.TABLE_NAME, null);
System.out.println("METHOD 2");
// METHOD #2 - SQLITEDATABASE QUERY()
// SWITCH TO MORE GENERAL QUERY() METHOD
c = sqdb.query(true, StudentTable.TABLE_NAME, new String[] { StudentTable.STATE }, null, null, null, null, null, null);
...
System.out.println("METHOD 3");
// METHOD #3 - SQLITEQUERYBUILDER
query = SQLiteQueryBuilder.buildQueryString(true, StudentTable.TABLE_NAME, new String[] { StudentTable.STATE },null, null, null, null, null);
System.out.println(query);
c = sqdb.rawQuery(query, null);

DISTINCT子句也相对简单直接,它允许您在查询中指定,对于给定的列,您只想返回具有该列不同值的行子集。需要注意的是,为了使DISTINCT子句有意义,一个列必须在您的查询中被指定。

对于这个查询的结果是:

DISTINCT 和 LIMIT 子句

这确实是我们当前表的案例!最后但同样重要的是,让我们来看看LIMIT子句:

/*
* LIMIT Clause
*/
System.out.println("METHOD 1");
// METHOD #1 - SQLITEDATABASE RAWQUERY()
c = sqdb.rawQuery("SELECT * from " + StudentTable.TABLE_NAME + " LIMIT 0,3", null);
...
System.out.println("METHOD 2");
// METHOD #2 - SQLITEDATABASE QUERY()
// SWITCH TO MORE GENERAL QUERY() METHOD
c = sqdb.query(false, StudentTable.TABLE_NAME, null, null, null, null, null, null, "3");
System.out.println("METHOD 3");
// METHOD #3 - SQLITEQUERYBUILDER
query = SQLiteQueryBuilder.buildQueryString(false, StudentTable.TABLE_NAME, null, null, null, null, null, "3");
System.out.println(query);
c = sqdb.rawQuery(query, null);

在我之前的示例中,我们会注意到一些事情。首先,在我们的查询中,请注意我们遵循DISTINCT子句与想要应用它的列,即State列。本质上,我们要求查询只返回数据库中每个州的不同行。换句话说,我们想要知道学生来自哪些州,并且每个州只想要一行数据。另外值得一提的是,我们已经更改了之前使用的query()语句——这次改为使用更通用的query()方法,该方法具有指定DISTINCT子句的参数。

  • LIMIT n, m

  • LIMIT n

第一种格式告诉查询只返回 m 行(也就是说,限制返回的行数)从第 n 行开始。第二种格式简单告诉查询返回满足给定条件的第一个 n 行。第一种格式确实为我们提供了更大的灵活性,但是不幸的是,第二种和第三种格式都不允许我们利用这种格式(由于它自动为我们构建查询的方式),而原始 SQL 查询(原始 SQL 查询)可以执行任何有效的 SQL 查询。这是一个小示例,展示了执行原始 SQL 查询给我们的多用途性,是交换灵活性和便利性以及抽象性的完美示例。

DISTINCT 和 LIMIT 子句

是的,看起来不错!在所有方法中,尽管我们没有指定任何WHERE筛选器,但我们仍然只得到了预期的前三个有效结果。

在这一部分,我们查看了一些内置于 SQL 语言中的子句,它们允许我们控制数据。通过逐个引入这些子句,希望你能首先看到所有谜题的碎片。然后,当你需要实现自己的数据库时,你将能够把碎片拼凑起来,执行强大的查询,快速返回有意义的数据。然而,在我们结束本章之前,让我们看看一些高级查询,它们需要更多时间来掌握和理解,但同样会为你增加一个工具。

ORDER BY 和 GROUP BY 子句

在这一部分,我们将查看 SQL 语言中一些更高级和更细微的功能以及它们在 Android 各种 SQL 便捷类中的实现。同样,在我们深入探讨这些特性之前,以下是我们在下一部分将要涵盖的内容列表:

  • ORDER BY子句

  • GROUP BY子句

  • HAVING筛选器

  • SQL 函数

  • 联接(JOINS)

让我们看看 SQL 中的ORDER BY子句:

public class AdvancedQueryActivity extends Activity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.main);
SchemaHelper sch = new SchemaHelper(this);
SQLiteDatabase sqdb = sch.getWritableDatabase();
/*
* ORDER BY Clause
*/
System.out.println("METHOD 1");
// METHOD #1 - SQLITEDATABASE RAWQUERY()
Cursor c = sqdb.rawQuery("SELECT * from " + StudentTable. TABLE_NAME + " ORDER BY " + StudentTable.STATE + " ASC", null);
while (c.moveToNext()) {
int colid = c.getColumnIndex(StudentTable.NAME);
int colid2 = c.getColumnIndex(StudentTable.STATE);
String name = c.getString(colid);
String state = c.getString(colid2);
System.out.println("GOT STUDENT " + name + " FROM " + state);
}
System.out.println("METHOD 2");
// METHOD #2 - SQLITEDATABASE QUERY()
c = sqdb.query(StudentTable.TABLE_NAME, null, null, null, null, null, StudentTable.STATE + " ASC");
while (c.moveToNext()) {
int colid = c.getColumnIndex(StudentTable.NAME);
int colid2 = c.getColumnIndex(StudentTable.STATE);
...
}
System.out.println("METHOD 3");
// METHOD #3 - SQLITEQUERYBUILDER
String query = SQLiteQueryBuilder.buildQueryString (false, StudentTable.TABLE_NAME, null, null, null, null, StudentTable.STATE + " ASC", null);
System.out.println(query);
c = sqdb.rawQuery(query, null);
while (c.moveToNext()) {
int colid = c.getColumnIndex(StudentTable.NAME);
int colid2 = c.getColumnIndex(StudentTable.STATE);
...
}
}
}

这里是ORDERBY子句的语法:

ORDER BY your_column ASC|DESC

在第一个方法中,我们看到了这个语法的实际应用,然后在后两个方法中,我们看到只需将列名后跟ASCDESC(作为字符串)传递给相应查询方法的ORDERBY参数。在后两个方法中,语法本质上相同,因此我在这里不过多赘述,但重要的是要了解 SQL ORDERBY子句的组成部分。在展示的所有三种方法中,我们都是通过州(state)列对结果子表进行排序,因此为了验证我们的查询,我们检查输出并看到以下内容:

ORDER BY 与 GROUP BY 子句

事实上,我们可以看到,结果行是按照州(state)以升序排列的。此外,就像在基本查询中一样,我们可以看到由SQLiteQueryBuilder类创建的输出 SQL 查询,并可以验证这是我们第一个方法中执行的相同查询。

现在,我们继续讨论GROUPBY子句:

/*
* GROUP BY Clause
*/
System.out.println("METHOD 1");
// METHOD #1 - SQLITEDATABASE RAWQUERY()
String colName = "COUNT(" + StudentTable.STATE + ")";
c = sqdb.rawQuery("SELECT " + StudentTable.STATE + "," + colName + " from " + StudentTable.TABLE_NAME + " GROUP BY " + StudentTable.STATE, null);
while (c.moveToNext()) {
int colid = c.getColumnIndex(StudentTable.STATE);
int colid2 = c.getColumnIndex(colName);
String state = c.getString(colid);
int count = c.getInt(colid2);
System.out.println("STATE " + state + " HAS COUNT " + count);
}
System.out.println("METHOD 2");
// METHOD #2 - SQLITEDATABASE QUERY()
c = sqdb.query(StudentTable.TABLE_NAME, new String[] { StudentTable.STATE, colName }, null, null, StudentTable.STATE, null, null);
while (c.moveToNext()) {
int colid = c.getColumnIndex(StudentTable.STATE);
int colid2 = c.getColumnIndex(colName);
}
SQLGROUPBY clausesSystem.out.println("METHOD 3");
// METHOD #3 - SQLITEQUERYBUILDER
query = SQLiteQueryBuilder.buildQueryString(false, StudentTable.TABLE_NAME, new String[] { StudentTable.STATE, colName }, null, StudentTable.STATE, null, null, null);
System.out.println(query);
c = sqdb.rawQuery(query, null);
while (c.moveToNext()) {
int colid = c.getColumnIndex(StudentTable.STATE);
int colid2 = c.getColumnIndex(colName);
}

现在,理解GROUPBY查询的结构再次变得至关重要,因为它与我们之前所见过的任何子句或筛选器都不同。结构如下:

SELECT your_column, aggregate_function(your_column) FROM your_table GROUP BY your_column

查询中最棘手的部分是aggregate_function(your_column)部分。在我们的例子中,我们使用 SQL 中所谓的COUNT()函数,顾名思义,它只是计算查询(或子查询)返回的行数,并返回计算值。在 SQL 中你可以使用任何数量的aggregate_functions,但现在我们先坚持使用COUNT(),稍后当我们讨论 SQL 函数时,我会列出其他一些函数。

这里的思路很简单——首先我们选择一列来对数据进行分组(在我们的案例中,是按州),然后我们告诉查询返回两列:第一列是州本身,第二列是那个州在表中出现的次数(即表中状态的聚合数量)。你还会注意到,在第二种和第三种方法中,GROUPBY查询的完成方式非常简单,唯一棘手的部分是指定用COUNT()函数包裹的列名(看看我们如何声明字符串colName)。一旦你这样做了,其余部分就非常直观,表现得就像带有列的标准SELECT查询一样!注意COUNT()函数也可以接受一个*作为参数,它只是返回子表中所有行的计数。

现在,让我们看看我们的输出是什么:

ORDER BY 和 GROUP BY 子句

看吧!正如我们所预期的——我们的查询返回了每个状态以及它们各自的出现频率!

HAVING过滤器和聚合函数

现在,随着GROUPBY子句的出现,也有了HAVING过滤器。HAVING过滤器只能与GROUPBY子句一起使用,以前面的查询为例,假设我们想要按照表中状态的数量进行分组,但我们只关心出现特定次数的状态。使用HAVING过滤器,我们本质上可以构建这样的查询:先按照状态数量分组,然后只返回那些总计数大于或小于某个值的状态。

让我们看看以下代码,并仔细注意我是如何构建查询的(它将与GROUPBY查询非常相似,但在最后加上了一个额外的过滤器):

/*
* HAVING Filter
*/
System.out.println("METHOD 1");
// METHOD #1 - SQLITEDATABASE RAWQUERY()
String colName = "COUNT(" + StudentTable.STATE + ")";
c = sqdb.rawQuery("SELECT " + StudentTable.STATE + "," + colName + " from " + StudentTable.TABLE_NAME + " GROUP BY " + StudentTable.STATE + " HAVING " + colName + " > 1", null);
while (c.moveToNext()) {
int colid = c.getColumnIndex(StudentTable.STATE);
int colid2 = c.getColumnIndex(colName);
}
System.out.println("METHOD 2");
// METHOD #2 - SQLITEDATABASE QUERY()
c = sqdb.query(StudentTable.TABLE_NAME, new String[] { StudentTable.STATE, colName }, null, null, StudentTable.STATE, colName + " > 1", null);
System.out.println("METHOD 3");
// METHOD #3 - SQLITEQUERYBUILDER
query = SQLiteQueryBuilder.buildQueryString(false, StudentTable.TABLE_NAME, new String[] { StudentTable.STATE, colName }, null, StudentTable.STATE, colName + " > 1", null, null);
System.out.println(query);
c = sqdb.rawQuery(query, null);

你看,就是这样。再次注意我在第一种方法中的查询结构,以及它是如何转化为第二种和第三种方法的查询便捷方法中的HAVING参数的。现在让我们看看查询结果如何,以及它是否从输出中排除了 AR:

HAVING 过滤器和聚合函数

很完美——非常直观。之前我们遇到了COUNT()聚合函数,它与SUM()AVG()一样,是最受欢迎的聚合函数之一(完整列表请见:www.sqlite.org/lang_aggfunc.html)。这些函数,如它们的名字所暗示的,可以统计子表特定列返回的行总数,或者该列值的总和,或者该列值的平均值,等等。首先,让我们检查以下一些聚合函数(注意列名如何变化):

/*
* SQL Functions - MIN/MAX/AVG
*/
System.out.println("METHOD 1");
// METHOD #1 - SQLITEDATABASE RAWQUERY()
String colName = "MIN(" + StudentTable.GRADE + ")";
c = sqdb.rawQuery("SELECT " + colName + " from " + StudentTable.TABLE_NAME, null);
while (c.moveToNext()) {
int colid = c.getColumnIndex(colName);
int minGrade = c.getInt(colid);
System.out.println("MIN GRADE " + minGrade);
}
System.out.println("METHOD 2");
// METHOD #2 - SQLITEDATABASE QUERY()
colName = "MAX(" + StudentTable.GRADE + ")";
c = sqdb.query(StudentTable.TABLE_NAME, new String[] { colName }, null, null, null, null, null);
System.out.println("METHOD 3");
// METHOD #3 - SQLITEQUERYBUILDER
colName = "AVG(" + StudentTable.GRADE + ")";
query = SQLiteQueryBuilder.buildQueryString(false, StudentTable.TABLE_NAME, new String[] { colName }, null,
null, null, null, null);
System.out.println(query);
c = sqdb.rawQuery(query, null);
while (c.moveToNext()) {
int colid = c.getColumnIndex(colName);
double avgGrade = c.getDouble(colid);
System.out.println("AVG GRADE " + avgGrade);
}

所以,这里我们使用这三种方法中的每一种来测试不同的聚合函数。结果如下所示:

HAVING 过滤器和聚合函数

引用之前表的状况后,你可以快速验证输出的数字,并确认这些函数确实在按预期工作。除了聚合函数(通常用于数值类型的列),SQLite 还提供了一系列其他核心函数,帮助你操作从字符串到日期类型等所有内容。这些核心函数的完整列表可以在www.sqlite.org/lang_corefunc.html找到,但现在,让我们来看几个例子:

/*
* SQL Functions - UPPER/LOWER/SUBSTR
*/
System.out.println("METHOD 1");
// METHOD #1 - SQLITEDATABASE RAWQUERY()
String colName = "UPPER(" + StudentTable.NAME + ")";
c = sqdb.rawQuery("SELECT " + colName + " from " + StudentTable.TABLE_NAME, null);
while (c.moveToNext()) {
int colid = c.getColumnIndex(colName);
String upperName = c.getString(colid);
System.out.println("GOT STUDENT " + upperName);
}
System.out.println("METHOD 2");
// METHOD #2 - SQLITEDATABASE QUERY()
colName = "LOWER(" + StudentTable.NAME + ")";
c = sqdb.query(StudentTable.TABLE_NAME, new String[] { colName }, null, null, null, null, null);
System.out.println("METHOD 3");
// METHOD #3 - SQLITEQUERYBUILDER
colName = "SUBSTR(" + StudentTable.NAME + ",1,4)";
query = SQLiteQueryBuilder.buildQueryString(false, StudentTable.TABLE_NAME, new String[] { colName }, null,
null, null, null, null);
System.out.println(query);
c = sqdb.rawQuery(query, null);

再次,以下是这些核心函数的相关输出:

HAVING 过滤器和聚合函数

现在,关于在 SQLite 中运行这些函数与在 Java 端执行它们相比能带来多大的性能提升,这是一个有争议的问题,并且高度依赖于你的数据库大小和你调用的函数。例如,一些字符串操作函数可能不会提供像其他更复杂的聚合函数那样的性能提升。实际上,我们将在下一节更深入地研究 SQLite 与 Java 的比较,但不管怎样,了解 SQLite 中可用的函数并添加到你的武器库中总是更好的!

最后,是时候看看使用SQLiteQueryBuilder的好处了(到目前为止,很多语法与SQLiteDatabase中的query()方法非常相似),我们来看看如何利用这个便捷类来执行更复杂的连接:

/*
* SQL JOINS
*/
SQLiteQueryBuilder sqb = new SQLiteQueryBuilder();
// NOTICE THE SYNTAX FOR COLUMNS IN JOIN QUERIES
String courseIdCol = CourseTable.TABLE_NAME + "." + CourseTable.ID;
String classCourseIdCol = ClassTable.TABLE_NAME + "." + ClassTable.COURSE_ID;
String classIdCol = ClassTable.TABLE_NAME + "." + ClassTable.ID;
sqb.setTables(ClassTable.TABLE_NAME + " INNER JOIN " + CourseTable.TABLE_NAME + " ON (" + classCourseIdCol + " = " + courseIdCol + ")");
String[] cols = new String[] { classIdCol, ClassTable.COURSE_ID, CourseTable.NAME };
query = sqb.buildQuery(cols, null, null, null, null, null, null);
System.out.println(query);
c = sqdb.rawQuery(query, null);
while (c.moveToNext()) {
int colid = c.getColumnIndex(0);
int colid2 = c.getColumnIndex(cols[1]);
int colid3 = c.getColumnIndex(cols[2]);
int rowId = c.getInt(colid);
int courseId = c.getInt(colid2);
String courseName = c.getString(colid3);
System.out.println(rowId + " || COURSE ID " + courseId + " || " + courseName);
}

首先,让我指出一些与JOIN语句相关的问题。本质上,JOIN语句允许你根据某些列值连接两个表。例如,在我们的案例中,我们的模式构建了一个用于班级的表,每个班级都映射了学生 ID 和课程 ID。但是,假设我们不仅想要快速知道班级映射是什么,还想要知道每个映射的课程名称(即课程的名称和哪些学生在上这门课)。我们不需要返回所有的班级映射以及课程列表(即请求两个表回来)然后手动进行这些查找,我们可以使用 SQL 的JOIN语句返回一个联合表。

现在,由于在进行JOIN语句时我们请求返回多个表,通常当你要求返回特定列时,你需要指定该列来自哪个表。例如,考虑两个表都有 ID 字段的情况——在这种情况下,仅仅请求 ID 列会导致错误,因为不清楚你真正想要的是哪个表的 ID 列。这就是我们在初始化字符串courseIdCol, classIdColclassCourseIdCol时所做的事情,语法如下:

table_name.column_name

然后在我们的SQLiteQueryBuilder类中,我们使用setTables()方法来格式化我们的JOIN语句。同样,你可以看到我们在上一个示例中使用的确切语法,但一般的格式是首先指定你想连接的两个表,然后告诉查询你想使用哪种类型的JOIN(在我们的案例中,我们想使用INNER JOIN)。之后,你需要告诉查询对哪两列执行JOIN,在我们的案例中,我们希望通过课程 ID 连接这两个表,因此我们指定了Class表的课程 ID 列以及Course表对应的课程 ID 列。这样做,JOIN语句就知道对于每个班级映射,它应该取课程 ID,然后转到Course表找到相应的课程 ID,并将该表的行附加到Class表。关于不同类型的 JOIN 以及每种 JOIN 的语法深入讨论,我邀请你查看www.w3schools.com/sql/sql_join.asp并阅读文档。上一个JOIN语句的输出如下:

HAVING 过滤和聚合函数

这样你就可以立即看到查询的语法以及结果。

SQL 与 Java 性能比较

那么,SQL 语言究竟有多强大和高效呢?在前两节中,我们探讨了 SQL 的基本和更高级功能 - 所有这些功能(理论上)仅用 Java 就可以模仿(也就是说,仅用最基础的SELECT语句获取整个表,并用 Java if语句解析等)。然而,现在是探索在 SQLite 端过滤和操作我们的数据是否真的有实际优势(相对于在 Java 端),如果有,它提供了多少优势的时候了。因此,首先,我们需要一个更大的数据集来更好地说明性能的改进。

首先,我们创建一个具有新架构的新表,该表仅包含姓名、州和收入列 - 可以将这个看作是一个美国数据库,包含每个家庭的名字、他们居住的州以及家庭收入。该表有 17,576 行 - 考虑到一些实际应用程序表的规模,这并不算多 - 但希望这个测试表能说明一些性能差异。让我们从WHERE过滤器开始:

public class PerformanceActivity extends Activity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.main);
TestSchemaHelper sch = new TestSchemaHelper(this);
SQLiteDatabase sqdb = sch.getWritableDatabase();
// TEST WHERE FILTER PERFORMANCE //
// SQL OPTIMIZED
long start = System.nanoTime();
String query = SQLiteQueryBuilder.buildQueryString(false, TestTable.TABLE_NAME, new String[] { TestTable.NAME }, TestTable.INCOME + " > 500000", null, null, null, null);
System.out.println(query);
Cursor c = sqdb.rawQuery(query, null);
int numRows = 0;
while (c.moveToNext()) {
int colid = c.getColumnIndex(TestTable.NAME);
String name = c.getString(colid);
numRows++;
}
System.out.println("RETRIEVED " + numRows);
System.out.println((System.nanoTime() - start) / 1000000 + " MILLISECONDS");
c.close();
// JAVA OPTIMIZED
start = System.nanoTime();
query = SQLiteQueryBuilder.buildQueryString(false, TestTable.TABLE_NAME, new String[] { TestTable.NAME,
TestTable.INCOME }, null, null, null, null, null);
System.out.println(query);
c = sqdb.rawQuery(query, null);
numRows = 0;
while (c.moveToNext()) {
int colid = c.getColumnIndex(TestTable.NAME);
int colid2 = c.getColumnIndex(TestTable.INCOME);
String name = c.getString(colid);
int income = c.getInt(colid2);
if (income > 500000) {
numRows++;
}
}
System.out.println("RETRIEVED " + numRows);
System.out.println((System.nanoTime() - start) / 1000000 + " MILLISECONDS");
c.close();
}
}

在 SQLite 方面,我们仅使用了一个WHERE过滤器,它返回给我们表中所有家庭收入超过 500,000 的家庭。在 Java 方面,我们获取整个表,并遍历每一行,使用if语句执行相同的过滤。我们可以验证输出的行是相同的,同时比较两种方法的速度:

SQL 与 Java 性能比较

我们可以看到,这里性能提升了近 5 倍!接下来,让我们看看使用GROUPBY子句时性能的提升。在 SQLite 方面,我们只需在 states 列上执行一个GROUPBY语句,并统计每个州有多少家庭。然后,在 Java 方面,我们将请求整个表格,并手动遍历每一行,使用标准的Map对象来跟踪每个州及其相应的计数,如下所示:

// TEST GROUP BY PERFORMANCE //
// SQL OPTIMIZED
start = System.nanoTime();
String colName = "COUNT(" + TestTable.STATE + ")";
query = SQLiteQueryBuilder.buildQueryString(false, TestTable. TABLE_NAME, new String[] { TestTable.STATE,
colName }, null, TestTable.STATE, null, null, null);
System.out.println(query);
c = sqdb.rawQuery(query, null);
while (c.moveToNext()) {
int colid = c.getColumnIndex(StudentTable.STATE);
int colid2 = c.getColumnIndex(colName);
String state = c.getString(colid);
int count = c.getInt(colid2);
System.out.println("STATE " + state + " HAS COUNT " + count);
}
System.out.println((System.nanoTime() - start) / 1000000 + " MILLISECONDS");
c.close();
// JAVA OPTIMIZED
start = System.nanoTime();
query = SQLiteQueryBuilder.buildQueryString(false, TestTable. TABLE_NAME, new String[] { TestTable.STATE },
null, null, null, null, null);
System.out.println(query);
c = sqdb.rawQuery(query, null);
Map<String, Integer> map = new HashMap<String, Integer>();
while (c.moveToNext()) {
int colid = c.getColumnIndex(TestTable.STATE);
String state = c.getString(colid);
if (map.containsKey(state)) {
int curValue = map.get(state);
map.put(state, curValue + 1);
} else {
map.put(state, 1);
}
}
for (String key : map.keySet()) {
System.out.println("STATE " + key + " HAS COUNT " + map. get(key));
}
System.out.println((System.nanoTime() - start) / 1000000 + " MILLISECONDS");
c.close();

让我们看看我们做得如何:

SQL 与 Java 性能对比

所以在这种情况下,我们看到了性能的提升,但不太明显,效率提高了 33%。需要注意的是,这些统计数据高度依赖于您的表模式和表大小,所以对这些数字要持保留态度。然而,这些小实验的目标只是让我们了解这两种方法如何比较。最后,让我们看看像 SQL 中的avg()这样的内置聚合函数与 Java 相比如何。两种方法的代码如下:

// TEST AVERAGE PERFORMANCE //
// SQL OPTIMIZED
start = System.nanoTime();
colName = "AVG(" + TestTable.INCOME + ")";
query = SQLiteQueryBuilder.buildQueryString(false, TestTable.TABLE_NAME, new String[] { colName }, null, null,
null, null, null);
System.out.println(query);
c = sqdb.rawQuery(query, null);
while (c.moveToNext()) {
int colid = c.getColumnIndex(colName);
double avgGrade = c.getDouble(colid);
System.out.println("AVG INCOME " + avgGrade);
}
System.out.println((System.nanoTime() - start) / 1000000 + " MILLISECONDS");
c.close();
// JAVA OPTIMIZED
start = System.nanoTime();
colName = TestTable.INCOME;
query = SQLiteQueryBuilder.buildQueryString(false, TestTable.TABLE_NAME, new String[] { colName }, null, null,
null, null, null);
System.out.println(query);
c = sqdb.rawQuery(query, null);
SQL language performancecheckingdouble sumIncomes = 0.0;
double numIncomes = 0.0;
while (c.moveToNext()) {
int colid = c.getColumnIndex(colName);
int income = c.getInt(colid);
sumIncomes += income;
numIncomes++;
}
System.out.println("AVG INCOME " + sumIncomes / numIncomes);
System.out.println((System.nanoTime() - start) / 1000000 + " MILLISECONDS");
c.close();

快速查看输出结果:

SQL 与 Java 性能对比

哇——无需多言。两种方法的结果相同,但使用 SQL 函数时,性能提高了 16 倍。

总结

在本章中,我们从关注 Android OS 开始,查看了可用哪些类型的查询方法。我们了解到,与 SQLite 数据库交互有三种众所周知的方式,有些方式比其他方式更“方便”,有些则更灵活、更强大。

然而,我们也看到,尽管每种方法都有其优缺点,但所有三种查询方法最终都能执行相同的查询,只是使用了不同的语法集或不同的参数集。这时,我们从这些方法本身转移开,更多地关注查询本身,从简单的查询开始,这些查询从最基本的SELECT查询到允许您指定特定列和行的更复杂的查询。后来,我们讨论了更高级的查询,这些查询从ORDERBYGROUPBY查询到最复杂、最深入的JOIN语句。

最后,作为我们这些好奇且注重性能的程序开发者,我们在上一节中比较了 SQL 和 Java 的执行速度,在 SQL 和 Java 中实施各种查询,然后运行它们以观察各自的执行速度。我们看到,在每种情况下,能够将你所需的功能嵌入到 SQL 查询中,与在 Java 中执行相同功能相比,都可以提供性能提升(在一个案例中,它提供了高达 16 倍的性能提升)。因此,本节的结论是,当可能时,应尽量在 SQL 端而非 Java 端处理数据,这将帮助你优化速度和内存使用!

但在我们继续之前,让我们花点时间来总结一下我们已经学到的知识。在之前的第二章中,使用 SQLite 数据库,我们学习了如何在 Android 应用程序中实现 SQLite 数据库架构,刚才我们又了解了内置于 SQL 中的所有不同特性,这些特性最终能让你以极其强大和高效的方式处理数据。但是,如果你想要访问用户 Android 设备上的现有数据呢?每个 Android 设备都包含大量的数据,其中许多数据可供外部应用程序查询,因此在开发应用程序时这一点很重要。此外,如果你想要将数据库和架构暴露给其他应用程序呢?如果你正在构建一个任务列表应用程序,并希望允许其他应用程序(可能是基于日历的应用程序)查询用户的现有任务,该怎么办?所有这些功能都是通过一个称为ContentProvider的机制实现的,接下来的两章我们将详细讲解这个在 Android 中极其重要的类。

第四章:使用内容提供者

到目前为止,我们在本书中已经完成了很多工作!在仅仅三章中,我们已经了解了从简单的、不起眼的SharedPreferences类到功能强大且复杂的 SQLite 数据库的各种数据存储机制,SQLite 数据库配备了各种查询方法和类,它们利用同样强大的 SQL 语言。

然而,假设你已经掌握了前三章的内容,并且从头开始成功构建了应用程序的数据库模式,现在该应用程序已经在市场上运行。现在,假设你想创建第二个应用程序,扩展第一个应用程序的功能,并且需要访问你原始应用程序的数据库。或者,也许你并不需要创建第二个应用程序,你只是想通过让外部应用程序访问和集成你的数据库来更好地推广你的应用程序。

或者,也许你从未想过要构建自己的数据库,而只是想利用每个 Android 设备上已经存在的丰富数据,这些数据可以随时查询!在本章中,我们将学习如何使用ContentProvider类完成所有这些事情,最后我们将花一些时间讨论实际用例,探讨为什么你可能需要通过ContentProvider公开你的数据库模式。

内容提供者

让我们先来回答这个问题:ContentProvider究竟是什么?为什么我需要与这个ContentProvider交互?

ContentProvider本质上是位于开发人员和存储所需数据的数据库模式之间的一个接口。为什么需要这个中介接口呢?考虑以下(真实)场景:

在 Android 操作系统中,用户的联系人列表(这包括电话号码、地址、生日以及与联系人相关的许多其他数据字段)存储在用户设备上相当复杂的数据库模式中。设想一个场景,作为开发人员,我想查询用户的联系人电话号码。

想想看,如果我只想访问一个或两个字段,却要学习整个数据库的模式,这会有多不方便?或者,如果每次谷歌更新 Android 操作系统并调整联系人模式(相信我,这已经发生了好几次了),我都必须重新学习模式并相应地重构我的查询,这会有多不方便?

正是因为这些原因,才存在这样的中介——这样,人们就不需要直接与模式交互,只需通过内容提供者查询即可。现在,请注意,每次谷歌更新其联系人模式时,他们都需要确保重新调整他们对Contacts内容提供者的实现;否则我们通过内容提供者进行的查询可能会失败。

换句话说,本章的大部分内容以及ContentProvider类的实现,都会让你想起我们之前在编写数据库便捷方法时的操作。如果你选择通过内容提供者公开你的数据,你需要定义外部应用程序如何查询你的数据,如何插入新数据或更新现有数据等。这些都需要你重写和实现的方法。

但现在让我们更细致一些。从开始到结束实现一个内容提供者有许多部分和步骤,所以首先,让我们开始概述这一部分,并查看所有这些步骤:

  • 定义数据模型(通常是 SQLite 数据库,然后扩展ContentProvider类)

  • 定义其统一资源标识符(URI)

  • 在 Manifest 文件中声明内容提供者

  • 实现ContentProvider的抽象方法(query(), insert(), update(), delete(), getType()onCreate()

现在,让我们从定义数据模型开始。通常,数据模型类似于 SQLite 数据库(虽然它不一定是),然后简单地扩展ContentProvider类。在我的例子中,我选择实现了一个非常简单的数据库架构,只包含一个表——公民表,旨在复制一个标准的数据库,用于跟踪具有唯一 ID(如社会安全 ID)、姓名、注册状态,以及在我的案例中报告的收入。首先,让我们定义这个CitizensTable类及其架构:

public class CitizenTable {
public static final String TABLE_NAME = "citizen_table";
/**
* DEFINE THE TABLE
*/
// ID COLUMN MUST LOOK LIKE THIS
public static final String ID = "_id";
public static final String NAME = "name";
public static final String STATE = "state";
public static final String INCOME = "income";
/**
* DEFINE THE CONTENT TYPE AND URI
*/
// TO BE DISCUSSED LATER. . .
}

很直观。现在让我们创建一个扩展了SQLiteOpenHelper类的类(就像我们在上一章所做的那样),但这次我们将把它声明为一个内部类,其中外部类扩展了ContentProvider类:

public class CitizenContentProvider extends ContentProvider {
private static final String DATABASE_NAME = "citizens.db";
private static final int DATABASE_VERSION = 1;
public static final String AUTHORITY =
"jwei.apps.dataforandroid.ch4.CitizenContentProvider";
// OVERRIDE AND IMPLEMENT OUR DATABASE SCHEMA
private static class DatabaseHelper extends SQLiteOpenHelper{
DatabaseHelper(Context context) {
super(context,DATABASE_NAME,null,DATABASE_VERSION);
}
@Override
public void onCreate(SQLiteDatabase db) {
// CREATE INCOME TABLE
db.execSQL("CREATE TABLE " + CitizenTable.TABLE_NAME +
" (" + CitizenTable.ID + " INTEGER PRIMARY KEY
AUTOINCREMENT," + CitizenTable.NAME + " TEXT," +
CitizenTable.STATE + " TEXT," + CitizenTable.INCOME +
" INTEGER);");
}
@Override
public void onUpgrade(SQLiteDatabase db, int oldVersion,
int newVersion) {
Log.w("LOG_TAG", "Upgrading database from version " +
oldVersion + " to " + newVersion +
", which will destroy all old data");
// KILL PREVIOUS TABLES IF UPGRADED
db.execSQL("DROP TABLE IF EXISTS " +
CitizenTable.TABLE_NAME);
// CREATE NEW INSTANCE OF SCHEMA
onCreate(db);
}
}
private DatabaseHelper dbHelper;
// NOTE THE DIFFERENT METHODS THAT NEED TO BE IMPLEMENTED
@Override
public boolean onCreate() {
// . . .
}
@Override
public int delete(Uri uri, String where, String[] whereArgs){
// . . .
}
@Override
public String getType(Uri uri) {
// . . .
}
@Override
public Uri insert(Uri uri, ContentValues initialValues) {
// . . .
ContentProviderContentProviderabout}
@Override
public Cursor query(Uri uri, String[] projection, String
selection, String[] selectionArgs, String sortOrder) {
// . . .
}
@Override
public int update(Uri uri, ContentValues values, String where,
String[] whereArgs) {
// . . .
}
}

你不必将 SQLite 数据库声明为内部类——对于我来说,这仅仅使实现稍微容易一些,并且所有内容都集中在一个地方。在任何情况下,你会注意到数据模型本身的实现与之前完全相同——重写onCreate()方法并创建你的表,然后重写onUpdate()方法并删除/重新创建表。在我们刚才看到的框架中,你还会看到由于扩展了ContentProvider类而需要实现的各种方法(这将在下一节中介绍)。

我们刚才看到的代码唯一不同的地方是包含了以下字符串:

public static final String AUTHORITY =
"jwei.apps.dataforandroid.ch4.CitizenContentProvider";

这个权限是标识提供者的内容——不一定是路径。我的意思是,稍后我们会看到,你可以定义整个路径(这被称为 URI),以指导查询到数据库架构中的正确位置。

在我们的内容提供者中,我们将允许开发人员以两种方式之一查询我们的数据库:

content://jwei.apps.dataforandroid.ch4.CitizenContentProvider/citizen

content://jwei.apps.dataforandroid.ch4.CitizenContentProvider/citizen/#

这两个完全指定的路径是我们将在内容提供者中注册的,根据开发者请求的路径,内容提供者将知道如何查询我们的数据库。这些意味着什么——注意,两者都以content://前缀开始,这只是告诉对象这是一个指向内容提供者的 URI(就像http://告诉浏览器路径指向一个网页)。

在前缀之后,我们指定权限,以便对象知道要访问哪个内容提供者,之后我们有后缀/citizen/citizen/#。前者我们将简单地定义为基本查询——开发者只需发出一个标准查询,并在query()方法中传递任何过滤器。后者适用于开发者已经知道公民的 ID(即社会安全号码)并且只想获取表中的特定行。我们不必强迫开发者使用带有 ID 的WHERE过滤器,我们可以简化操作,允许开发者以路径的形式指定WHERE过滤器。

如果所有这些听起来仍然让人困惑,最直观的类比可能是:当你注册一个互联网域名时,你必须指定一个基础 URL,一旦注册,浏览器就会知道如何根据这个基础 URL 找到其他文件的位置。同样,在我们的例子中,我们在Android Manifest(我们应用程序的主板)中指定我们想要公开一个内容提供者,并定义了到它的路径。一旦注册,任何开发者想要访问我们的内容提供者时,他/她必须指定这个基础 URI(即权限),并且他/她还需要通过完成 URI 的路径来指定他们要进行的查询类型。关于如何定义ContentProvider URI 的更多信息,我邀请您查看:

Android 开发者指南关于内容提供者的部分

但现在,让我们快速查看一下如何在 Android 的 Manifest 文件中声明你的提供者,之后,我们将深入到实现的核心部分,即重写抽象方法:

<?xml version="1.0" encoding="utf-8"?>
<manifest

package="jwei.apps.dataforandroid"
android:versionCode="1"
android:versionName="1.0">
<application android:icon="@drawable/icon"
android:label="@string/app_name">
<provider
android:name=
"jwei.apps.dataforandroid.ch4.CitizenContentProvider"
android:authorities=
"jwei.apps.dataforandroid.ch4.CitizenContentProvider"/>
</application>
</manifest>

同样,这非常直观。你需要为你的内容提供者定义一个名称和权限——实际上,如果给定的基础 URI 作为权限不合适,Manifest 文件会报错,只要它能编译,你就知道可以开始了!现在,让我们继续学习内容提供者更复杂的实现部分。

实现query方法

现在我们已经构建了数据模型,定义了表的权限和 URI,并在我们的 Android Manifest 文件中成功声明了它,是时候编写类的主体并实现其六个抽象方法了。我们将从onCreate()query()方法开始:

public class CitizenContentProvider extends ContentProvider {
private static final String DATABASE_NAME = "citizens.db";
private static final int DATABASE_VERSION = 1;
public static final String AUTHORITY =
"jwei.apps.dataforandroid.ch4.CitizenContentProvider";
private static final UriMatcher sUriMatcher;
private static HashMap<String, String> projectionMap;
// URI MATCH OF A GENERAL CITIZENS QUERY
private static final int CITIZENS = 1;
// URI MATCH OF A SPECIFIC CITIZEN QUERY
private static final int SSID = 2;
private static class DatabaseHelper extends SQLiteOpenHelper {
// . . .
}
private DatabaseHelper dbHelper;
@Override
public boolean onCreate() {
// HELPER DATABASE IS INITIALIZED
dbHelper = new DatabaseHelper(getContext());
return true;
}
@Override
public int delete(Uri uri, String where, String[] whereArgs){
// . . .
}
@Override
public String getType(Uri uri) {
// . . .
}
@Override
public Uri insert(Uri uri, ContentValues initialValues) {
// . . .
}
@Override
public Cursor query(Uri uri, String[] projection,
String selection, String[] selectionArgs, String sortOrder) {
SQLiteQueryBuilder qb = new SQLiteQueryBuilder();
qb.setTables(CitizenTable.TABLE_NAME);
switch (sUriMatcher.match(uri)) {
case CITIZENS:
qb.setProjectionMap(projectionMap);
break;
case SSID:
String ssid =
uri.getPathSegments(). get(CitizenTable.SSID_PATH_POSITION);
qb.setProjectionMap(projectionMap);
// FOR QUERYING BY SPECIFIC SSID
qb.appendWhere(CitizenTable.ID + "=" + ssid);
break;
default:
throw new IllegalArgumentException ("Unknown URI " + uri);
}
SQLiteDatabase db = dbHelper.getReadableDatabase();
Cursor c = qb.query(db, projection, selection,
selectionArgs, null, null, sortOrder);
// REGISTERS NOTIFICATION LISTENER WITH GIVEN CURSOR
// CURSOR KNOWS WHEN UNDERLYING DATA HAS CHANGED
c.setNotificationUri(getContext().getContentResolver(),
uri);
return c;
ContentProviderContentProviderquery method, implementing}
@Override
public int update(Uri uri, ContentValues values, String where,
String[] whereArgs) {
// . . .
}
// INSTANTIATE AND SET STATIC VARIABLES
static {
sUriMatcher = new UriMatcher(UriMatcher.NO_MATCH);
sUriMatcher.addURI(AUTHORITY, "citizen", CITIZENS);
sUriMatcher.addURI(AUTHORITY, "citizen/#", SSID);
// PROJECTION MAP USED FOR ROW ALIAS
projectionMap = new HashMap<String, String>();
projectionMap.put(CitizenTable.ID, CitizenTable.ID);
projectionMap.put(CitizenTable.NAME, CitizenTable.NAME);
projectionMap.put(CitizenTable.STATE, CitizenTable.STATE);
projectionMap.put(CitizenTable.INCOME,
CitizenTable.INCOME);
}
}

所以,让我们先从简单的事情开始。你会注意到首先在我们定义了 SQLite 数据库(通过扩展SQLiteOpenHelper类)之后,我们声明了一个全局的DatabaseHelper变量,并在我们的onCreate()方法中初始化它。onCreate()方法是在活动通过ContentResolver对象(我们稍后会讨论)请求打开我们的特定内容提供者之后自动调用的。当然,任何其他的初始化工作也应该在这里进行,但在我们的例子中,我们只想初始化与数据库的连接。

完成这些后,让我们看看我们在最后声明的那些静态变量。projectionMap的作用是允许你为列设置别名。在大多数内容提供者中,这种映射看起来可能有些没有意义,因为你只是告诉内容提供者将表的列映射到它们自己(正如我们在onCreate()query()方法的实现中所做的那样)。然而,在某些情况下,对于更复杂的架构(即包含联合表的那些),能够重命名和为表的列设置别名可以使访问内容提供者的数据更加直观。

现在还记得我们之前提到的两个路径吗(即/citizen/citizen/#)?这里我们所做的就是实例化一个UriMatcher对象,通过addURI()方法来定义这些路径。

在高层次上,这个方法定义了一组映射关系——它告诉我们的ContentProvider类,任何带有路径/citizen的查询都应该映射到带有CITIZENS标志的行为上。同理,带有路径/citizen/#的查询应该映射到带有SSID标志的行为上(这些标志都是在类的顶部定义的)。这种功能对开发者很有用,因为它允许他如果提前知道公民的 ID,就可以高效地查询。

这些标志通常出现在switch语句中,所以现在我们将注意力集中在query()方法上。它首先初始化一个SqliteQueryBuilder类(我们在前面的章节中花了大量时间研究它),然后使用我们的UriMatcher对象来匹配传入的 URI。换句话说,UriMatcher所做的就是查看请求的路径,首先判断它是否是有效的路径(如果不是,我们会抛出一个带有错误unknown URI的异常)。一旦它看到开发者提交了一个有效的 URI,它就会返回该路径关联的标志(在我们的例子中就是CITIZENSSSID),此时我们可以使用switch语句来导航到正确的功能。

一旦你理解了高级层面的操作,其余部分现在应该相当直接和熟悉。如果用户刚刚提交了一个常规查询(即带有CITIZENS标志的查询),那么我们需要做的就是定义投影映射和将被查询的表名。再次强调,如果用户想要直接访问我们表中的某一行,那么通过在路径中指定社会保险 ID,我们可以使用以下这行代码解析出该公民信息:

String ssid =
uri.getPathSegments().get(CitizenTable.SSID_PATH_POSITION);

不用太担心SSID_PATH_POSITION变量——我们在这里所做的就是获取传入的 URI 并将其分解为路径段。一旦有了路径段,我们将获取第一个路径段(随后SSID_PATH_POSITION被设置为1,我们很快就会看到),因为在我们的示例中,只会有一个路径段传入。

现在,一旦我们获得了查询中传入的期望的社会保险 ID,我们需要做的就是将其附加到WHERE过滤器上,其余部分就是我们之前看到的内容——获取可读数据库,并填充SQLiteDatabasequery()方法。

最后我要提到的是,在成功发起查询并获取指向数据的Cursor之后,由于我们将内容提供者暴露给了设备上的所有外部应用,可能会有多个应用同时访问我们的数据库,这种情况下我们的数据可能会发生变化。因此,我们告诉返回的Cursor监听其基础数据发生的任何变化,这样当有变化发生时,Cursor就会知道更新自身,进而更新可能使用我们的Cursor的任何 UI 组件。

实现 delete 和 update 方法

在这一点上,希望一切都有意义,所以让我们继续看看delete()update()方法,这两个方法在结构上将与query()方法非常相似:

public class CitizenContentProvider extends ContentProvider {
private static final String DATABASE_NAME = "citizens.db";
private static final int DATABASE_VERSION = 1;
public static final String AUTHORITY =
"jwei.apps.dataforandroid.ch4.CitizenContentProvider";
private static final UriMatcher sUriMatcher;
private static HashMap<String, String> projectionMap;
// URI MATCH OF A GENERAL CITIZENS QUERY
private static final int CITIZENS = 1;
// URI MATCH OF A SPECIFIC CITIZEN QUERY
private static final int SSID = 2;
private static class DatabaseHelper extends SQLiteOpenHelper {
// . . .
}
private DatabaseHelper dbHelper;
@Override
public boolean onCreate() {
// HELPER DATABASE IS INITIALIZED
dbHelper = new DatabaseHelper(getContext());
return true;
}
@Override
public int delete(Uri uri, String where, String[] whereArgs) {
SQLiteDatabase db = dbHelper.getWritableDatabase();
int count;
switch (sUriMatcher.match(uri)) {
case CITIZENS:
// PERFORM REGULAR DELETE
count = db.delete(CitizenTable.TABLE_NAME, where,
whereArgs);
break;
case SSID:
// FROM INCOMING URI GET SSID
String ssid =
uri.getPathSegments(). get(CitizenTable.SSID_PATH_POSITION);
// USER WANTS TO DELETE A SPECIFIC CITIZEN
String finalWhere = CitizenTable.ID+"="+ssid;
// IF USER SPECIFIES WHERE FILTER THEN APPEND
if (where != null) {
finalWhere = finalWhere + " AND " + where;
}
count = db.delete(CitizenTable.TABLE_NAME,
finalWhere, whereArgs);
break;
default:
throw new IllegalArgumentException ("Unknown URI " + uri);
}
getContext().getContentResolver().notifyChange(uri, null);
return count;
}
ContentProviderContentProviderupdate() methods, implementing@Override
public String getType(Uri uri) {
// . . .
}
@Override
public Uri insert(Uri uri, ContentValues initialValues) {
// . . .
}
@Override
public Cursor query(Uri uri, String[] projection,
String selection, String[] selectionArgs, String sortOrder) {
// . . .
}
@Override
public int update(Uri uri, ContentValues values, String where,
String[] whereArgs) {
SQLiteDatabase db = dbHelper.getWritableDatabase();
int count;
switch (sUriMatcher.match(uri)) {
case CITIZENS:
// GENERAL UPDATE ON ALL CITIZENS
count = db.update(CitizenTable.TABLE_NAME, values,
where, whereArgs);
break;
case SSID:
// FROM INCOMING URI GET SSID
String ssid =
uri.getPathSegments(). get(CitizenTable.SSID_PATH_POSITION);
// THE USER WANTS TO UPDATE A SPECIFIC CITIZEN
String finalWhere = CitizenTable.ID+"="+ssid;
if (where != null) {
finalWhere = finalWhere + " AND " + where;
}
// PERFORM THE UPDATE ON THE SPECIFIC CITIZEN
count = db.update(CitizenTable.TABLE_NAME, values,
finalWhere, whereArgs);
break;
default:
throw new IllegalArgumentException ("Unknown URI " + uri);
}
getContext().getContentResolver().notifyChange(uri, null);
return count;
}
// INSTANTIATE AND SET STATIC VARIABLES
static {
// . . .
}
}

我们可以看到,这两个语句背后的逻辑与query()方法非常相似。我们看到在delete()方法中,我们首先获取可写数据库(注意在这种情况下我们不需要SQLiteQueryBuilder的帮助,因为我们正在删除某物而不是查询任何内容),然后将传入的 URI 指向我们的UriMatcher。一旦UriMatcher验证了路径,它就会将其指向适当的标志,在这一点上我们可以相应地调整功能。

在我们的案例中,带有CITIZEN路径规范的任何查询都变成了一个标准的delete()语句,而带有SSID路径规范的查询变成了带有对表 ID 列额外WHERE过滤器的delete()语句。再次强调,这里的直觉是我们正在从数据库中删除一个特定的公民。看看以下代码片段:

String finalWhere = CitizenTable.ID+"="+ssid;
// IF USER SPECIFIES WHERE FILTER THEN APPEND
if (where != null) {
finalWhere = finalWhere + " AND " + where;
}

请注意我们是如何将 ID 过滤器添加到用户可能指定的原始WHERE过滤器上的。在你的实现中记住这样的细节很重要——即开发者可能在路径规范中与 ID 一起传递了额外的参数,因此你的最终WHERE过滤器应该考虑所有这些因素。剩下的唯一细节就在这一行:

getContext().getContentResolver().notifyChange(uri, null);

这里我们所做的是请求Context和发起此调用的ContentResolver,并通知它对底层数据的更改已成功完成。为什么这很重要,当我们讨论如何将Cursors绑定到 UI 时会更加清晰,但现在考虑一个情况,在你的活动中,你将数据的行显示为列表。自然,每次底层数据库中的数据行发生更改时,你都希望你的列表反映出这些变化,这就是为什么我们需要在方法末尾通知这些变化。

现在,关于update()方法我不会说太多,因为其逻辑与delete()方法相同——唯一的不同在于你对可写 SQLite 数据库调用的差异。所以,让我们继续前进,用getType()insert()方法完成我们的实现!

实现insert()getType()方法

是时候实现最后两个方法,完成我们的ContentProvider实现了。让我们看看:

public class CitizenContentProvider extends ContentProvider {
private static final String DATABASE_NAME = "citizens.db";
private static final int DATABASE_VERSION = 1;
public static final String AUTHORITY =
"jwei.apps.dataforandroid.ch4.CitizenContentProvider";
private static final UriMatcher sUriMatcher;
private static HashMap<String, String> projectionMap;
// URI MATCH OF A GENERAL CITIZENS QUERY
private static final int CITIZENS = 1;
// URI MATCH OF A SPECIFIC CITIZEN QUERY
private static final int SSID = 2;
private static class DatabaseHelper extends SQLiteOpenHelper {
// . . .
}
private DatabaseHelper dbHelper;
@Override
public boolean onCreate() {
// . . .
}
@Override
public int delete(Uri uri, String where, String[] whereArgs) {
// . . .
}
@Override
public String getType(Uri uri) {
switch (sUriMatcher.match(uri)) {
case CITIZENS:
return CitizenTable.CONTENT_TYPE;
case SSID:
return CitizenTable.CONTENT_ITEM_TYPE;
default:
throw new IllegalArgumentException("Unknown URI " + uri);
}
}
@Override
public Uri insert(Uri uri, ContentValues initialValues) {
// ONLY GENERAL CITIZENS URI IS ALLOWED FOR INSERTS
// DOESN'T MAKE SENSE TO SPECIFY A SINGLE CITIZEN
if (sUriMatcher.match(uri) != CITIZENS) { throw new IllegalArgumentException("Unknown URI " + uri); }
// PACKAGE DESIRED VALUES AS A CONTENTVALUE OBJECT
ContentValues values;
if (initialValues != null) {
values = new ContentValues(initialValues);
} else {
values = new ContentValues();
}
SQLiteDatabase db = dbHelper.getWritableDatabase();
long rowId = db.insert(CitizenTable.TABLE_NAME,
CitizenTable.NAME, values);
if (rowId > 0) {
Uri citizenUri = ContentUris.withAppendedId(CitizenTable.CONTENT_URI, rowId);
// NOTIFY CONTEXT OF THE CHANGE
getContext().getContentResolver().notifyChange(citizenUri,
null);
ContentProviderContentProvidergetType() method, implementingreturn citizenUri;
}
throw new SQLException("Failed to insert row into " + uri);
}
@Override
public Cursor query(Uri uri, String[] projection,
String selection, String[] selectionArgs, String sortOrder) {
// . . .
}
@Override
public int update(Uri uri, ContentValues values, String where,
String[] whereArgs) {
// . . .
}
// INSTANTIATE AND SET STATIC VARIABLES
static {
// . . .
}
}

首先,让我们处理getType()方法。这个方法只是返回请求给定 URI 的数据对象的多用途互联网邮件扩展(MIME)类型,这实际上意味着你为数据的每一行(或行)指定了一个可区分的数据类型。这使得开发者(如果需要)能够确定指向你的表的Cursor是否确实检索到有效的公民对象。为你的数据指定 MIME 类型的规则是:

  • vnd.android.cursor.item/用于单一记录

  • vnd.android.cursor.dir/用于多条记录

接着,我们将在CitizenTable类中定义我们的 MIME 类型(这也是我们定义列和架构的地方):

public class CitizenTable {
public static final String TABLE_NAME = "citizen_table";
/**
* DEFINE THE TABLE
*/
// . . .
/**
* DEFINE THE CONTENT TYPE AND URI
*/
// THE CONTENT URI TO OUR PROVIDER
public static final Uri CONTENT_URI = Uri.parse("content://" +
CitizenContentProvider.AUTHORITY + "/citizen");
// MIME TYPE FOR GROUP OF CITIZENS
public static final String CONTENT_TYPE =
"vnd.android.cursor.dir/vnd.jwei512.citizen";
// MIME TYPE FOR SINGLE CITIZEN
public static final String CONTENT_ITEM_TYPE =
"vnd.android.cursor.item/vnd.jwei512.citizen";
// RELATIVE POSITION OF CITIZEN SSID IN URI
public static final int SSID_PATH_POSITION = 1;
}

所以现在我们已经定义了我们的 MIME 类型,剩下的就是将 URI 再次传递给UriMatcher并返回相应的 MIME 类型。

最后但同样重要的是,我们有我们的insert()方法。这个方法略有不同,但不是显著不同。唯一的区别在于,当插入某物时,传递一个SSID URI 路径是没有意义的(想想看——如果你正在插入一个新的公民,你怎么可能已经有一个想要传递给 URI 的社会安全 ID)。因此,在这种情况下,如果没有传递带有CITIZEN路径规范的 URI,我们就抛出一个错误。否则,我们继续并简单地获取我们的可写数据库并将值插入到我们的内容提供者中(这我们之前也见过)。

就是这样!目标是看到完整的实现后,所有的部分能够联系在一起,并且你开始直观地了解我们的ContentProvider类中发生了什么。只要直观上讲得通,当你自己编程和实现内容提供者时,其余部分就会随之而来!

现在,在讨论通过内容提供者暴露数据的具体原因之前,让我们快速了解一下如何与内容提供者(现在我们先使用我们自己的)交互,并随后介绍ContentResolver类,到现在为止我们已经多次提到过它。现在看起来可能很快,但不用担心——我们将在接下来的章节中专门介绍最常用的内容提供者:Contacts内容提供者。

与 ContentProvider 交互

在这一点上,我们已经成功实现了自己的内容提供者,现在可以被外部应用程序读取、查询和更新(假设已授予适当的权限)!要交互内容提供者,第一步是从你的Context获取相关的ContentResolver。这个类与SQLiteDatabase类非常相似,因为它具有标准的insert(), query(), update()delete()方法(实际上,这两个类的方法语法和参数也非常相似),但它特别设计用于通过开发者传入的 URI 与内容提供者交互。

让我们看看你如何在Activity类中实例化一个ContentResolver,然后使用路径规范插入和查询数据:

public class ContentProviderActivity extends Activity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.main);
ContentResolver cr = getContentResolver();
ContentValues contentValue = new ContentValues();
contentValue.put(CitizenTable.NAME, "Jason Wei");
contentValue.put(CitizenTable.STATE, "CA");
contentValue.put(CitizenTable.INCOME, 100000);
cr.insert(CitizenTable.CONTENT_URI, contentValue);
contentValue = new ContentValues();
contentValue.put(CitizenTable.NAME, "James Lee");
contentValue.put(CitizenTable.STATE, "NY");
contentValue.put(CitizenTable.INCOME, 120000);
cr.insert(CitizenTable.CONTENT_URI, contentValue);
contentValue = new ContentValues();
contentValue.put(CitizenTable.NAME, "Daniel Lee");
contentValue.put(CitizenTable.STATE, "NY");
contentValue.put(CitizenTable.INCOME, 80000);
cr.insert(CitizenTable.CONTENT_URI, contentValue);
// QUERY TABLE FOR ALL COLUMNS AND ROWS
Cursor c = cr.query(CitizenTable.CONTENT_URI, null, null,
null, CitizenTable.INCOME + " ASC");
// LET THE ACTIVITY MANAGE THE CURSOR
startManagingCursor(c);
int idCol = c.getColumnIndex(CitizenTable.ID);
int nameCol = c.getColumnIndex(CitizenTable.NAME);
int stateCol = c.getColumnIndex(CitizenTable.STATE);
int incomeCol = c.getColumnIndex(CitizenTable.INCOME);
while (c.moveToNext()) {
int id = c.getInt(idCol);
String name = c.getString(nameCol);
String state = c.getString(stateCol);
int income = c.getInt(incomeCol);
System.out.println("RETRIEVED ||" + id + "||" + name +
"||" + state + "||" + income);
}
System.out.println("-------------------------------");
// QUERY BY A SPECIFIC ID
Uri myC = Uri.withAppendedPath(CitizenTable.CONTENT_URI,
"2");
Cursor c1 = cr.query(myC, null, null, null, null);
// LET THE ACTIVITY MANAGE THE CURSOR
startManagingCursor(c1);
while (c1.moveToNext()) {
int id = c1.getInt(idCol);
String name = c1.getString(nameCol);
String state = c1.getString(stateCol);
int income = c1.getInt(incomeCol);
System.out.println("RETRIEVED ||" + id + "||" + name +
"||" + state + "||" + income);
}
}
}

这里发生的情况是,我们首先向数据库中插入三行,这样公民表现在看起来像这样:

ID 姓名 收入
1 魏佳森 加利福尼亚 100000
2 詹姆斯·李 纽约 120000
3 丹尼尔·李 纽约 80000

在这里,我们使用内容解析器对我们的表进行一般查询(即,只需传入基本的 URI 路径规范),按收入递增的顺序。然后,我们使用内容解析器通过SSID路径规范进行特定查询。为此,我们使用了以下静态方法:

Uri myC = Uri.withAppendedPath(CitizenTable.CONTENT_URI, "2");

这将基本内容 URI 从以下形式转换:

content://jwei.apps.dataforandroid.ch4.CitizenContentProvider/citizen

转换为以下形式:

content://jwei.apps.dataforandroid.ch4.CitizenContentProvider/citizen/2

因此,为了验证我们的结果,让我们看看输出的内容:

与 ContentProvider 交互

从之前的截图中,我们可以看到两个查询确实输出了正确的数据行!

关于上一个例子,我要说的最后一件事(因为大部分语法和Cursor处理与之前章节的例子相同)是关于startManagingCursor()方法。在之前的章节中,你会注意到每次我通过query()打开一个Cursor,我必须确保在Activity结束时关闭它,否则操作系统会抛出各种悬挂Cursor的警告。然而,使用startManagingCursor()便利方法,Activity会为你管理Cursor的生命周期,确保在Activity销毁自身之前关闭它,等等。通常,让Activity为你管理Cursors是一个好主意。

实际使用场景

所以,现在你知道了如何实现和访问内容提供者,你可能会挠头自问:我为什么要这么做呢?

有哪些实际的使用场景可以证明内容提供者的价值,让你愿意经历构建内容提供者的额外麻烦,而不是仅仅扩展SQLiteOpenHelper并编写一些便利方法?

好吧,ContentProvider的一个特点是它允许你将数据暴露给所有外部应用程序,我们可以从这里开始我们的头脑风暴。比方说你正在运营一家小型(或大型)初创公司,你开发了一款允许用户查找餐厅并预订的应用程序。

当然,你的应用程序很可能会将这些预订信息存储在某种类型的数据库中,这样用户每次打开应用程序时都能看到他们之前所做的预订。但是,假设你暴露了你的内容提供者,并将其变成了一个本地 API(或许对于一些人来说,将内容提供者视为这样的东西最为简单)——在这种情况下,其他应用程序,比如日历应用程序或任务列表应用程序,可以开发一些特殊功能,使它们能够与该用户的餐厅预订同步它们的日历和/或任务!

在这个例子中,你有两个应用程序,各自具有特定的功能,利用内容提供者的力量为用户提供出色的体验(用户满意意味着你的应用程序会获得好评!)。

在结束本章并进入下一章之前,让我们再头脑风暴一个例子。Android OS(以及谷歌公司)的一大优点是搜索功能!因此,在 Android OS 中,有一个原生的快速搜索应用程序,它通常作为设备主屏幕上的一个小部件出现(更多信息请参见developer.android.com/resources/articles/qsb.html)。

这个快速搜索小部件特别酷,因为它允许你搜索所有声明为可搜索的数据库。那么,让你的数据库可搜索需要什么前提条件呢?你已经猜到了——必须通过内容提供者。再次强调,只有通过内容提供者公开你的数据,任何应用程序(无论是本地还是第三方)才能读取和访问你的数据库。

假设你正在编写一个短信应用程序,因此你维护一个内容提供者,存储了你与朋友的所有最新短信。你可以添加的一个很酷的功能是声明你的内容提供者为可搜索的,然后在你的内容提供者中指定搜索应在哪些字段上进行(在这种情况下,它可能是包含短信正文的字段)。完成这些操作后,用户可以使用主屏幕的搜索小部件快速搜索,无缝地浏览与朋友的短信!

最终,内容提供者背后的原则和概念是简单的,实现只是工作的一半——另一半是要有创意,思考出创新且有用的应用场景。

总结

在本章中,我们详细介绍了ContentProvider是什么以及如何实现它,因此我们看到了大量的代码。然而,从概念上讲,ContentProvider相当简单:你首先定义一个扩展了SQLiteOpenHelper的内部类,然后指定如何根据传递给每个方法的指令查询和/或修改 SQLite 数据库。这些指令以 URI 的形式出现,因此在每个方法中,你将解析 URI 的不同路径并执行适当的功能。

然后,我们快速了解了如何通过ContentResolver与新的内容提供者(实际上是与任何内容提供者)进行交互,ContentResolver可以从Context获取,然后用于query(), insert(), delete()update()相应的内容提供者。

最后,我们花了一些时间从代码中抽身,考虑实际使用内容提供者的方法。在开发应用程序时,这始终是一个重要的练习,这也是本书的一个目标——为你提供这些技术的底层实现细节以及高层动机和使用场景。

之前我提到过,Android 操作系统充满了预先存在的内容提供者,任何开发者都可以自由查询和更新。这是事实,系统中内置的一些更常见的内容提供者包括媒体和日历内容提供者。然而,最重要且最常使用的ContentProvider无疑是Contacts内容提供者——这是内置于操作系统中的数据库架构,用于存储用户的联系人列表。

在下一章中,我们将全力以赴学习和理解这个Contacts内容提供者,它的架构,以及如何与它互动以完成标准查询和更新。

第五章:查询联系人表

在本书前面,我们探讨了如何通过重写SQLiteOpenHelper类为我们的应用程序构建一个 SQLite 数据库。然后,我们通过引入ContentProvider类扩展了对 Android 上数据库的理解,它允许我们将 SQLite 数据库暴露给外部应用程序,以及更一般地暴露给 Android 操作系统本身。

然而,尽管设计和实现你自己的数据库是一项强大的技能,但利用用户设备上现有的数据同样有益。通常,这意味着查询现有内容提供者以获取各种类型的数据,但尤其重要的是联系人内容提供者,到目前为止它是最常被查询的内容提供者。

在本章中,我们将首先探索联系人内容提供者的结构(即其模式),然后查看查询联系人和其相关元数据的不同方式。

联系人内容提供者结构

理解联系人内容提供者的模式架构是挑战的一半。由于潜在地与一个联系人关联的数据量很大,因此在设计一个既灵活又强大到足以满足每个用户需求的模式上,我们必须做了大量的工作。在下面的表格中,我勾勒出了这个模式是如何布局的,然后我们将从高层次上探讨这个模式是如何工作的,再深入到模式中的每个表:

联系人内容提供者结构

所以你现在看到的就是这些——看起来并不是特别令人畏惧,对吧?当然,之前显示的列只是每个表中实际列的一个子集,但希望这足以让你了解这些表是如何协同工作的。如果你想查看每个表中的所有列,我建议你查看以下链接:

Android 联系人数据提供者参考

Android 联系人原始数据提供者参考

Android 联系人数据提供者参考

首先让我们从高层次思考这个模式。最顶层是联系人表。在之前版本的 Android(API 级别 4 及以下)中,这几乎是你可以使用的一切。它只是一个典型的、直观的联系人表,包含了每个联系人的唯一 ID 以及他们的姓名、电话号码、电子邮件等。

然后事情变得复杂了。突然间,Android 2.0(API 级别 5 及以上)出现了,用户可以将联系人同步到 Facebook、Twitter、谷歌以及众多其他服务。仅有一个简单的Contacts表还有意义吗?每个来源的每个联系人是否都是独立的一行?我们如何知道哪些行实际上指的是同一个联系人?

因此,谷歌不得不开发一个引用Contacts表的第二层表格——这些表格被称为Raw Contacts。用户拥有的每个联系人都是由原始联系人汇总而成的,其中每个原始联系人代表来自特定来源的单个联系人。例如,你有一个朋友,并且你已经将这个联系人同步到了 Facebook 和 Twitter。这位朋友就会有两组Raw Contact表格,一组描述了他/她在 Facebook 的元数据,另一组描述了他/她在 Twitter 的元数据。这两个原始联系人都会指向Contacts表中的单一条目。

但是等等,之前每个联系人的元数据基本上限于几个电话号码和电子邮件,现在由于社交网络,每个联系人都有大量的元数据可用。我们如何存储所有这些元数据?每个联系人的最新状态消息或最新推文?我们是否只需要一个拥有大约三十列的巨大Raw Contacts表?

最好不要——这很可能不是内存的好用法,因为那个表格可能会相当稀疏。因此,谷歌团队决定创建一个第三层表格,称为Data表。这些Data表都引用一个原始联系人,后者再次引用一个联系人。因此,在 Android 操作系统中描述联系人的方式基本上是这样的:一个联系人是特定于各个来源(即 Facebook 或 Twitter)的原始联系人的汇总,每个原始联系人又是一组独立数据表的汇总,每个数据表包含一种类型的数据(即电话号码、电子邮件、状态消息等)。这是发生的事情的高级视图,下一节我们将探讨如何实际查询这些表以获取常见字段,如电话号码和电子邮件。

现在,有许多技术细节可以完全描述架构中发生的事情,但现在我将以此节的一个简短讨论结束,介绍原始联系人之间实际如何进行汇总。

系统会自动汇总原始联系人,因此每次你创建新联系人或同步新账户到现有联系人时,都会以DEFAULT聚合模式创建一个原始联系人,这告诉系统将这个原始联系人与其他引用同一联系人的原始联系人进行汇总。但你可以明确指定你想要对该原始联系人进行的汇总类型,以下是可以选择的选项:

  • AGGREGATION_MODE_DEFAULT 允许自动汇总的默认状态

  • AGGREGATION_MODE_DISABLED 不允许自动聚合,原始联系人将不会被聚合

  • AGGREGATION_MODE_SUSPENDED 自动聚合被禁用,但是,如果原始联系人之前已经聚合,那么它将保持聚合状态

这三种是聚合模式,你可以针对每个原始联系人进行更新和调整。至于聚合是如何完成的,它主要是通过匹配名字和/或昵称来完成的,如果没有名字,那么将尝试使用电话号码和电子邮件进行匹配。

到目前为止,你应该对Contacts内容提供者的样子有了相当的了解,因此我们将继续看一些代码!

查询联系人

首先,让我们从一个简单的查询开始,这个查询针对的是Contacts表,并返回联系人 ID,每个联系人的名字(请记住,这是一个聚合的显示名称),以及他们的lookup键。这个lookup键对于Contacts内容提供者来说是一个相对较新的概念,它被设计成比使用传统的行 ID 更可靠的方式来引用Contacts

这是因为行 ID 往往不可靠,特别是对于像Contacts内容提供者这样的内容提供者,它可能有多个应用程序引用,并可能同时对其进行更新。假设你尝试通过其行 ID 引用联系人,但用户设备上的另一个应用程序之前已经对Contacts数据库进行了更改,以至于该行 ID 的联系人现在不同了,或者可能现在它已经不再那里了!相反,lookup键是每个原始联系人的服务器端标识符的串联(换句话说,它是原始联系人元数据的一个函数),将更加稳定。但解释就到这里,让我们看看一个简单查询可能长什么样:

public class ContactsQueryActivity extends Activity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.main);
/*
* QUERY EXAMPLE
*/
// FIRST QUERY FOR CONTACT LOOKUPS
Cursor c = getContentResolver().query(
ContactsContract.Contacts.CONTENT_URI,
new String[] { ContactsContract.Contacts._ID, ContactsContract.Contacts.DISPLAY_NAME, ContactsContract.Contacts.LOOKUP_KEY }, ContactsContract.Contacts.DISPLAY_NAME + " IS NOT NULL", null, null);
startManagingCursor(c);
int idCol = c.getColumnIndex(Contacts._ID);
int nameCol = c.getColumnIndex(Contacts.DISPLAY_NAME);
int lookCol = c.getColumnIndex(Contacts.LOOKUP_KEY);
// USE A MAP TO KEEP TRACK OF LOOKUP VALUES
Map<String, String> lookups = new HashMap<String, String>();
while (c.moveToNext()) {
int id = c.getInt(idCol);
String name = c.getString(nameCol);
String lookup = c.getString(lookCol);
lookups.put(name, lookup);
System.out.println("GOT " + id + " // " + lookup + " // " + name + " FROM CONTACTS");
}
}
}

因此,这里我们像上一章一样获取内容解析器,并传入Contacts CONTENT_URI。然后,我们遍历游标并获取我们在投影数组中请求的字段。注意,我还使用了一个Map来跟踪每个联系人的lookup键。在我的例子中,我将键设置为联系人的显示名称,但你可以使用任何你喜欢的数据结构来存储lookup键和/或联系人 ID。

如果你已经知道你的联系人的lookup键(可能它之前已经被缓存在某个地方),那么你可以使用以下代码片段中的lookup键直接访问联系人:

// ALTERNATIVELY - USE LOOKUP KEY LIKE THIS
Uri lookupUri = Uri.withAppendedPath( Contacts.CONTENT_LOOKUP_URI, lookups.get("Vicky Wei"));
Cursor c3 = getContentResolver().query(lookupUri, new String[] { Contacts.DISPLAY_NAME }, null, null, null);
if (c3.moveToFirst()) {
int nameCol = c3.getColumnIndex(Contacts.DISPLAY_NAME);
String displayName = c3.getString(nameCol);
System.out.println("GOT NAME " + displayName + " FOR LOOKUP KEY " + lookups.get("Vicky Wei"));
}
c3.close();

所以,在这里我们将lookup值附加到 URI 本身——类似于我们之前将行 ID 附加到标准内容 URI 以获取单个市民的方式。但是,这种方法的问题是,与传统的通过行 ID 匹配相比,通过lookup键匹配通常会有一些额外的开销。换句话说,你牺牲了一些速度性能,以获得更好的查询准确性。然而,Android 为你提供了另一种方法,旨在让你既提高准确性又提高性能:

Uri lookupUri = getLookupUri(contactId, lookupKey)

这个方法允许你首先通过联系人 ID 查找联系人——这是一个更快且仍然相当可靠的方法。但是,如果系统未能通过该联系人 ID 找到联系人,它会转而使用lookup键。在任何一种情况下,只要联系人存在,你就能保证获取到该联系人的正确lookupURI,但通常使用这种方法会给你带来很好的性能提升,而不会牺牲任何准确性。

既然你已经有了联系人 ID、lookup键和他们的名字,那么你如何查询更具体的元数据——比如他们的电话号码或电子邮件?让我们看看以下示例,其中我通过过滤他们的lookup键来请求联系人的电话号码和电话类型:

// THEN WITH LOOKUP KEYS - FIND SPECIFIC DATA FIELDS
Cursor c2 = getContentResolver().query( ContactsContract.Data.CONTENT_URI,
new String[] { ContactsContract.CommonDataKinds.Phone.NUMBER, Phone.TYPE },ContactsContract.Data.LOOKUP_KEY + "=?", new String[] { lookups.get("Vicky Wei") }, null);
startManagingCursor(c2);
int numberCol = c2.getColumnIndex(Phone.NUMBER);
int typeCol = c2.getColumnIndex(Phone.TYPE);
if (c2.moveToFirst()) {
String number = c2.getString(numberCol);
int type = c2.getInt(typeCol);
String strType = "";
switch (type) {
case Phone.TYPE_HOME:
strType = "HOME";
break;
case Phone.TYPE_MOBILE:
strType = "MOBILE";
break;
case Phone.TYPE_WORK:
strType = "WORK";
break;
default:
strType = "MOBILE";
break;
}
System.out.println("GOT NUMBER " + number + " OF TYPE " + strType + " FOR VICKY WEI");
}

请注意,我们省略了到PhoneData类的完整包路径,以再次让你了解架构的层次性质。在这里,由于我们现在针对的是Data表而不是Contact表,因此我们传递相应的Data CONTENT_URI。然后在投影参数中,我们请求电话号码以及电话类型,在选择参数中我确保通过lookup键进行过滤。成功查询后,我们只需移动光标(此时只有一个与 Vicky 相关的号码;否则,我们将使用while循环)并再次获取字段。注意,我们编写了一个简单的switch语句,它允许我们将作为整数返回的PHONE_TYPE转换成更友好的字符串。

最后但同样重要的是,让我们看看如何查询Raw Contacts表:

// NOW LOOK AT RAW CONTACT IDS
c = getContentResolver().query(
ContactsContract.RawContacts.CONTENT_URI,
new String[] { ContactsContract.RawContacts._ID, RawContacts.ACCOUNT_NAME, RawContacts.ACCOUNT_TYPE, RawContacts.CONTACT_ID }, null, null, null);
startManagingCursor(c);
int rawIdCol = c.getColumnIndex(RawContacts._ID);
int accNameCol = c.getColumnIndex(RawContacts.ACCOUNT_NAME);
int accTypeCol = c.getColumnIndex(RawContacts.ACCOUNT_TYPE);
int contactIdCol = c.getColumnIndex(RawContacts.CONTACT_ID);
while (c.moveToNext()) {
int rawId = c.getInt(rawIdCol);
String accName = c.getString(accNameCol);
String accType = c.getString(accTypeCol);
int contactId = c.getInt(contactIdCol);
System.out.println("GOT " + rawId + " // " + accName + " // " + accType + " REFRENCING CONTACT " + contactId);
}

这特别适用于如果你想查看特定来源(比如你只想了解 Facebook 上该联系人的元数据)的联系人元数据。那么你可能会通过ACCOUNT_NAMEACCOUNT_TYPE过滤Raw Contacts表,一旦你获得了与特定来源相关联的原始联系人 ID,你就可以查询与这些特定原始联系人 ID 相关联的Data表中的任何元数据!

现在,让我们快速了解一下如何修改联系人数据——更具体地说,是如何插入和更新联系人数据。需要注意的是,为了成功运行这些活动,我们将在Android Manifest文件中请求特殊权限。但现在,让我们继续关注代码,并确保在最后详细探讨所有权限问题。

修改联系人

以下示例的代码应该看起来非常熟悉。正如我之前所说,挑战的一半在于掌握模式并理解每个表如何与其他表交互(如果之前没有像这样展开模式,可能会非常困惑,可能需要浏览大量详细的文档)。假设我们想要为用户插入一个新的电话号码。我们应该引用哪个表的 URI 呢?

好吧,它必须是Data表中的一个,我们可能还需要传递数据的MIMETYPE,以便内容提供者确切知道要在哪个Data表中插入新行。在这种情况下,我们将指定电话内容类型,并传递一个数字和数字类型。我们唯一缺少的字段是 ID——这个新行应该进入哪个联系人的电话Data表?回想一下,每个Data表都指向一个Raw Contact表,传递相应联系人的原始联系人 ID 是合理的。

因此,我们尝试对每一个需要进行的插入、更新或删除操作都重复这一思考过程,最终代码如下所示:

public class ContactsQueryActivity extends Activity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.main);
/*
* INSERT EXAMPLE
*/
ContentValues values = new ContentValues();
// IN THIS CASE - EACH RAW ID IS JUST THE CONTACT ID
values.put(ContactsContract.Data.RAW_CONTACT_ID, 2);
values.put(Data.MIMETYPE, Phone.CONTENT_ITEM_TYPE);
values.put(Phone.NUMBER, "555-987-1234");
values.put(Phone.TYPE, Phone.TYPE_WORK);
Uri contactUri = getContentResolver().insert( Data.CONTENT_URI, values);
Cursor c4 = getContentResolver().query(contactUri, new String[] { Phone.NUMBER, Phone.TYPE }, null, null, null);
startManagingCursor(c4);
// READ BACK THE ROW
if (c4.moveToFirst()) {
String number = c4.getString(numberCol);
int type = c4.getInt(typeCol);
String strType = "";
switch (type) {
case Phone.TYPE_HOME:
strType = "HOME";
break;
case Phone.TYPE_MOBILE:
strType = "MOBILE";
break;
case Phone.TYPE_WORK:
strType = "WORK";
break;
default:
strType = "MOBILE";
break;
}
System.out.println("GOT NUMBER " + number + " OF TYPE " + strType + " FOR VICKY WEI");
}
}
}

在这里,我们使用内容解析器和一个ContentValues对象进行标准的插入操作。一旦我们插入了它,系统会返回新插入行的 URI,然后我们只需对该 URI 运行查询,并读取我们刚刚插入的数据,以验证插入是否成功。以下面的截图我会指出这一点。

现在,谷歌的开发者们提倡另一种插入方式,即使用批量插入。这是 Android 操作系统中的另一个相对较新的概念,是传统ContentValues类的一个变体。通过使用批量操作,你不仅可以一次性插入多行时获得相当大的性能提升(节省了从客户端到服务器端切换的时间),而且还能保证插入的原子性。这是一个花哨的数据库术语,意思是所有行要么全部插入,要么都不插入,这样如果在插入过程中发生错误,系统将确保回滚之前的插入,以保持数据库的一致性。

这些批量插入的语法如下所示,非常直观:

// NOW INSERT USING BATCH OPERATIONS
ArrayList<ContentProviderOperation> ops = new ArrayList<ContentProviderOperation>();
ops.add(ContentProviderOperation.newInsert(Data.CONTENT_URI)
.withValue(Data.RAW_CONTACT_ID, 3)
.withValue(Data.MIMETYPE, Email.CONTENT_ITEM_TYPE)
.withValue(Email.DATA, "daniel@stanford.edu")
.withValue(Email.TYPE, Email.TYPE_WORK)
.build());
try {
getContentResolver().applyBatch (ContactsContract.AUTHORITY, ops);
} catch (Exception e) {
e.printStackTrace();
System.out.println("ERROR: BATCH TRANSACTION FAILED");
}

为了结束本章内容,我们将快速了解一下如何使用这种新的批量操作机制来更新联系人的电子邮件:

/*
* UPDATE EXAMPLE
*/
ops = new ArrayList<ContentProviderOperation>();
ops.add(ContentProviderOperation.newUpdate(Data.CONTENT_URI)
.withSelection(Data.RAW_CONTACT_ID + "=? AND " + Email.TYPE + "=?",new String[] { "7", String.valueOf(Email. TYPE_WORK) }).withValue(Email.DATA,"james@android.com"). build());
try {
getContentResolver().applyBatch( ContactsContract.AUTHORITY, ops);
} catch (Exception e) {
e.printStackTrace();
System.out.println("ERROR: BATCH TRANSACTION FAILED");
}

就是这样!再次,我们认为我们可能需要指定原始联系人 ID,以便内容提供者知道要更新哪个Data表,以及Data表的MIMETYPE,以便内容提供者知道要更新哪个Data表。至于本节中所有查询、插入和更新的结果,请参考以下内容:

修改联系人

首先,我们看到了我的联系人列表中的所有联系人以及他们的lookup键、ID 和显示名称。然后,我们看到了从 Vicky 那里获取的电话号码,以及通过她的lookup键而不是联系人 ID 查找她的结果,以及对我们查询Raw Contacts表的跟进。注意,对于账户名称和账户类型,你会看到一堆空值,但这只是我在模拟器上运行代码的结果。当你尝试在完全同步且实时的联系人列表上运行代码时,预计会看到更多丰富多彩的结果。最后,我们只是看到了我们插入和更新的一些结果,并且可以通过实际查看联系人列表中的联系人来进一步验证我们的插入/更新是否成功,如下所示:

修改联系人

在这里,我们可以看到我们已经成功为联系人 Vicky 插入了一个工作号码,然后对于 Daniel,我们看到了以下内容:

修改联系人

这样,他现在确实有一个与我们指定的工作电子邮件地址正确对应的工作电子邮件。就是这样!希望现在你能够对Contacts内容提供者的架构以及构建有效查询或插入的通用语法有一个深入的理解。在考虑要传递哪些字段以及你真正想要查询的表时,请记住保持架构在心中。

设置权限

现在我们已经掌握了在不声明适当权限的情况下使用Contacts内容提供者的方法,你可能发现在尝试运行前面的代码时遇到了一些粗鲁的强制关闭。为了保护用户的个人联系人信息,防止潜在恶意应用程序的侵犯,Android 操作系统要求你在应用程序的Android Manifest文件中声明一些读取和写入权限。要做到这一点,你只需要在清单文件中添加以下两行内容:

<?xml version="1.0" encoding="utf-8"?>
<manifest 
package="jwei.apps.dataforandroid"
android:versionCode="1"
android:versionName="1.0">
<application android:icon="@drawable/icon" android:label= "@string/app_name">
<activity android:name=".ch5.ContactsQueryActivity" 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="5" />
<uses-permission android:name="android.permission.READ_CONTACTS"/>
<uses-permission android:name="android.permission.WRITE_CONTACTS"/> 
</manifest>

所以基本上,你只需要在清单文件中声明你希望既能读取也能写入(即修改)联系人(或者根据你的应用程序需求只声明其中一个)。这样,在用户下载你的应用程序之前,系统会提示用户你的应用程序需要这些权限,只要用户接受,你的应用程序就可以运行了!

概述

在本章中,我们通过掌握每个应用在每个设备上都可以使用的最广泛使用的内容提供者——Contacts内容提供者,扩展了我们对内容提供者的知识。我们从查看Contacts内容提供者的模式开始,由于各种社交网络来源,与给定联系人关联的元数据越来越多,这一模式变得日益复杂。为了解决这个问题,谷歌的团队决定通过一个简单称为Contacts表的一级表,接着是称为Raw Contact表的二级表,然后是简单称为Data表的三级表,来改变模式。每个联系人然后是一个特定于源(即 Facebook 或 Twitter)的原始联系人组的聚合,每个原始联系人又是一系列Data表的聚合,每个表都有自己的数据类型(即电话号码或电子邮件)。

之后,我们探讨了多种查询Contacts内容提供者的方法,以及多种在内容提供者中插入和更新现有联系人的方法。这在代码上相对简单(与我们之前章节看到的非常相似),再次证明了半数战斗在于理解模式并确保我们包含了所有适当的字段。

到目前为止,在这本书中,我们已经探讨了查询自己的以及外部数据库的方法,但每次我们都依赖于简单的系统打印语句来实际查看查询结果(我相信到现在你已经厌倦了看 DDMS 日志)。因此问题变成了——既然我已经知道如何实际构建和查询数据库,那么我应该如何设计活动,以便将这数据绑定到用户界面供用户查看和交互?这将是我们在下一章关注的重点,我们将探讨如何通过用户界面与数据库进行绑定和交互。

第六章:绑定到用户界面

在之前的五章中,我们已经涵盖了大量的内容——从轻量级数据存储形式(如SharedPreferences)到更重量级的数据存储形式(如 SQLite 数据库)。但是,对于每种数据存储方法和我们查看的每个示例——为了实际查看查询结果和后端数据操作的结果,我们不得不依赖非常简单的系统 IO 打印命令。

然而,作为移动开发者,我们的应用程序通常需要美观地显示这些数据查询的结果,同时也需要为用户提供直观的界面来存储和插入数据。

在本章中,我们将关注前者——将数据绑定到用户界面(UI),并特别关注各种允许我们以列表形式绑定数据的类(这是显示数据行最常见和直观的方式)。

SimpleCursorAdaptersListViews

在 Android 上有两种主要的数据检索方式,每种方式都有自己的ListAdapters类,这些类将知道如何处理和绑定传入的数据。我们熟悉的第一个数据检索方式是通过查询并获得Cursor对象。围绕CursorListAdapters的子类被称为CursorAdapter,在下一节中,我们将关注SimpleCursorAdapter,这是CursorAdapter最直接的实例。

正如我们所知,Cursor指向包含我们查询结果的行子表。通过遍历这个游标,我们能够检查每一行的字段,在之前的章节中,我们打印出了这些字段的值以检查返回的子表。现在,我们希望将子表的每一行转换为我们列表中对应的行。实现这一目标的第一步是设置一个ListActivity(更常见的Activity类的一个变体)。

顾名思义,ListActivity只是Activity类的一个子类,它带有允许你附加ListAdapters的方法。ListActivity类还允许你膨胀 XML 布局,这些布局包含列表标签。在我们的示例中,我们将使用一个非常基础的 XML 布局(名为list.xml),它只包含一个ListView标签,如下所示:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout 
android:orientation="vertical"
android:layout_width="fill_parent"
android:layout_height="wrap_content" >
<ListView
android:id="@android:id/android:list"
android:layout_width="fill_parent"
android:layout_height="wrap_content" />
</LinearLayout>

这是设置 Android 中所谓的ListView的第一步。类似于定义TextView允许你在Activity中看到一个文本块,定义ListView将允许你与Activity中的可滚动行对象列表进行交互。

直观地,你脑海中接下来应该的问题是:我在哪里定义每一行实际的样子?你不仅需要在某个地方定义实际的列表对象,而且每一行都应该有自己的布局。因此,为此我们在布局目录中创建了一个单独的list_entry.xml文件。

我即将使用的示例是查询Contacts内容提供者并返回一个列表,其中包含每个联系人的姓名、电话号码和电话号码类型。因此,我的列表中的每一行都应该包含三个TextView,每个数据字段一个。随后,我的list_entry.xml文件如下所示:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout 
android:orientation="vertical"
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:padding="10dip" >
<TextView
android:id="@+id/name_entry"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="28dip" />
<TextView
android:id="@+id/number_entry"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="16dip" />
<TextView
android:id="@+id/number_type_entry"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="#DDD"
android:textSize="14dip" />
</LinearLayout>

所以我们有一个垂直的LinearLayout,包含三个TextView,每个都有自己的正确定义的 ID 以及自己的审美属性(即文本大小和文本颜色)。

在设置方面——这就是我们所需要的全部!现在我们只需要创建ListActivity本身,加载list.xml布局,并指定适配器。为了了解如何完成所有这些操作,让我们先看一下代码,然后逐块分解:

public class SimpleContactsActivity extends ListActivity {
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.list);
// MAKE QUERY TO CONTACT CONTENTPROVIDER
String[] projections = new String[] { Phone._ID, Phone.DISPLAY_NAME, Phone.NUMBER, Phone.TYPE };
Cursor c = getContentResolver().query(Phone.CONTENT_URI, projections, null, null, null);
startManagingCursor(c);
// THE DESIRED COLUMNS TO BE BOUND
String[] columns = new String[] { Phone.DISPLAY_NAME, Phone.NUMBER, Phone.TYPE };
// THE XML DEFINED VIEWS FOR EACH FIELD TO BE BOUND TO
int[] to = new int[] { R.id.name_entry, R.id.number_entry, R.id.number_type_entry };
// CREATE ADAPTER WITH CURSOR POINTING TO DESIRED DATA
SimpleCursorAdapter cAdapter = new SimpleCursorAdapter(this, R.layout.list_entry, c, columns, to);
// SET THIS ADAPTER AS YOUR LIST ACTIVITY'S ADAPTER
this.setListAdapter(cAdapter);
}
}

那么这里发生了什么?嗯,代码的第一部分你现在应该已经熟悉了——我们只是在手机的联系人列表上执行查询(特别是针对联系人内容提供者的Phone表),并请求联系人的姓名、号码和号码类型。

接下来,SimpleCursorAdapter有两个参数,一个是字符串数组,一个是整数数组,它们代表Cursor列与 XML 布局视图之间的映射关系。在我们的例子中,如下所示:

// THE DESIRED COLUMNS TO BE BOUND
String[] columns = new String[] { Phone.DISPLAY_NAME, Phone.NUMBER, Phone.TYPE };
// THE XML DEFINED VIEWS FOR EACH FIELD TO BE BOUND TO
int[] to = new int[] { R.id.name_entry, R.id.number_entry, R.id.number_type_entry };

这样,DISPLAY_NAME列中的数据就会被绑定到 ID 为name_entryTextView上,依此类推。定义好这些映射关系后,下一步就是实例化SimpleCursorAdapter,这在以下这行代码中可以看到:

// CREATE ADAPTER WITH CURSOR POINTING TO DESIRED DATA
SimpleCursorAdapter cAdapter = new SimpleCursorAdapter(this, R.layout.list_entry, c, columns, to);

现在,SimpleCursorAdapter接受五个参数——第一个是Context,它基本上告诉CursorAdapter需要绑定行的父Activity。下一个参数是之前定义的 R 布局的 ID,这将告诉CursorAdapter每一行应该是什么样子,以及它可以在哪里加载相应的视图。接下来,我们传递Cursor,它告诉适配器底层数据实际是什么,最后,我们传递映射关系。

希望之前的代码是有意义的,SimpleCursorAdapter的参数也应该是有意义的。上一个Activity的结果可以在以下屏幕截图中看到:

SimpleCursorAdapters 和 ListViews

所有内容看起来都很好,除了电话号码下面漂浮的这些随机整数。为什么在每一行类型应该出现的地方有一堆 1、2、3 呢?回想一下前一章,电话号码类型不是作为字符串返回的,而是作为整数返回的。从那里通过一个简单的switch语句,我们可以很容易地将这些整数转换成更具描述性的字符串。

然而,你会很快发现,在我们非常简单直接地使用内置的SimpleCursorAdapter类时,我们没有任何地方可以实施任何允许我们将返回的整数转换为字符串的“特殊”逻辑。这正是重写SimpleCursorAdapter类变得必要的时候,因为只有这样我们才能完全控制如何在每一行中显示游标的数据。因此,我们继续下一部分,那里我们会看到这一点。

自定义CursorAdapter

在这一部分,我们将扩展SimpleCursorAdapter并尝试编写我们自己的CursorAdapter类,这将让我们在如何显示底层数据方面有更大的灵活性。我们自定义类的目标很简单——不是将电话号码类型显示为整数,而是找到一种方法将它们显示为可读的字符串。

在扩展SimpleCursorAdapter类之后,我们将需要重写并实现newView()方法,最重要的是bindView()方法。可选地,我们还可以自定义我们的构造函数,根据你的实现,这对于缓存和提高性能可能很有用(我们稍后会看到一个例子)。

在这里的概念是,每当一个新的行在 Android 设备的屏幕上实际显示时,newView()方法就会被调用。这意味着当用户在 Activity 的列表中滚动,并且新的行首次出现在设备的屏幕上时,这个newView()方法将被触发。因此,这个newView()的功能应该保持相对简单。在我的实现中,这意味着在给定上下文的情况下,我会请求相关的LayoutInflater类,并使用它来填充新行的布局(如在list_entry.xml中定义的)。

逻辑的核心随后在bindView()方法中发生。一旦调用了newView()方法并且行的实际布局初始化后,接下来被调用的方法就是bindView()。这个方法接收之前实例化的新 View 对象以及属于这个适配器类的Cursor作为参数。需要注意的是,传递进来的Cursor已经移动到了正确的索引位置。换句话说,适配器足够智能,能够传递给你一个指向与你正在创建的布局行相对应的数据行的Cursor!当然,没有看到代码并排比较,很难理解和看到这些方法。因此,在我继续之前,让我们快速地看一下:

public class CustomContactsAdapter extends SimpleCursorAdapter {
private int layout;
public CustomContactsAdapter(Context context, int layout, Cursor c, String[] from, int[] to) {
super(context, layout, c, from, to);
this.layout = layout;
}
@Override
public View newView(Context context, Cursor cursor, ViewGroup parent) {
final LayoutInflater inflater = LayoutInflater.from(context);
View v = inflater.inflate(layout, parent, false);
return v;
}
@Override
public void bindView(View v, Context context, Cursor c) {
int nameCol = c.getColumnIndex(Phone.DISPLAY_NAME);
int numCol = c.getColumnIndex(Phone.NUMBER);
int typeCol = c.getColumnIndex(Phone.TYPE);
String name = c.getString(nameCol);
String number = c.getString(numCol);
int type = c.getInt(typeCol);
String numType = "";
switch (type) {
case Phone.TYPE_HOME:
numType = "HOME";
break;
case Phone.TYPE_MOBILE:
numType = "MOBILE";
break;
case Phone.TYPE_WORK:
numType = "WORK";
break;
default:
numType = "MOBILE";
break;
}
// FIND THE VIEW AND SET THE NAME
TextView name_text = (TextView) v.findViewById (R.id.name_entry);
name_text.setText(name);
TextView number_text = (TextView) v.findViewById (R.id.number_entry);
number_text.setText(number);
TextView type_text = (TextView) v.findViewById
(R.id.number_type_entry);
type_text.setText(numType);
}
}

同样,你会注意到 newView() 方法的实现非常直接。你还会发现传递进来的 Context 对于每新增的一行是相同的——这意味着每次调用此方法时,实际上我请求的是同一个 LayoutInflater 对象。尽管在这个案例中没有明显差异,但像这样的小细节(即,不是持续请求相同的资源)是你可以优化列表性能的小方法。在这里,通过在构造函数中实例化一次 LayoutInflater 并每次重用它,我们可能节省了数百次不必要的请求。虽然这可能看起来是一个非常小的优化,但请记住,当涉及到列表时,尤其是在移动设备上,用户期望它们能够非常快速和响应迅速。随着时间的推移,一个卡顿的列表常常会成为用户的巨大烦恼,并且通常表示应用程序编写得不好。

现在来看 bindView() 方法。同样,流程是首先调用 newView() 并实例化新的一行,然后调用 bindView() 并传递这个新行的布局视图。这里我们也传递了一个 Cursor 对象,但重要的是要注意 Cursor 实际上指向的是下一行数据。换句话说,Cursor 并没有指向查询子表的第一行,而是指向单一行,并在幕后相应地递增。这就是我所说的 CursorAdapter 类是一个很好的类来使用,因为它如何为你处理底层的 Cursor,当列表上下滚动时。

至于我们绑定逻辑——非常简单。给定一个 Cursor,我们请求相应的字段及其各自值,由于我们也传递了那一行的 View 对象,我们只需要为每个 TextView 设置正确的字符串值。但是,请注意,这里我们有灵活性插入额外的逻辑,这允许我们处理电话号码类型作为整数返回的事实。因此,我们自然在这里包含了一个 switch 语句,而不是将整数设置到 type_text TextView 中,我们设置了一个可读的字符串值!

现在,尽管这个例子非常简单,但这个练习的目标是了解通过扩展 SimpleCursorAdapter 类并实现我们自己的 CursorAdapter,我们可以覆盖 bindView() 方法,并使用传递进来的 View 和 Cursor 对象以我们希望的任何方式自定义行的显示!

至于如何在实际中使用你自定义的 CursorAdapter 替换前面的 SimpleCursorAdapter 示例,只需替换以下这行代码:

SimpleCursorAdapter cAdapter = new SimpleCursorAdapter(this, R.layout.list_entry, c, columns, to);

以及这一行:

CustomContactsAdapter cAdapter = new CustomContactsAdapter(this, R.layout.list_entry, c, columns, to);

那么最后这一切看起来如何呢?让我们快速看一下:

自定义 CursorAdapter

在这里,我们看到在每一行中,我们不是简单地显示电话号码的整数类型,而是可以按需看到实际的易读字符串类型!现在看起来好多了。

基础适配器(BaseAdapters)和自定义基础适配器(Custom BaseAdapters)

之前我们提到,通常有两种方式来检索数据——第一种是Cursor对象的形式,第二种是对象列表的形式。在本节中,我们将关注后一种检索和处理数据的方法,以及如何将对象列表转换为可查看的数据行。

那么,在什么情况下我们实际上会有一个对象列表,而不是Cursor呢?到目前为止,我们的所有关注点都集中在构建 SQLite 数据库和内容提供者上,在所有情况下我们都返回了一个Cursor。但是,正如我们将在后续章节中看到的,数据存储实际上并不总是在移动设备端完成,而是在外部数据库完成。

在这些情况下,获取数据并不像简单地执行 SQLite 查询那么容易,而是需要通过网络通过 HTTP 请求来完成。此外,一旦获取到数据,它很可能是某种字符串格式(通常是 XML 或 JSON——关于这一点稍后会详细介绍),而不是解析这个字符串以获取数据,然后将其插入到 SQLite 数据库中,通常你会将每个字符串简单地转换为一个对象,并将其存储在标准列表中。为了处理对象列表,Android 有一种名为BaseAdapterListAdapter,我们将在本节中重写并剖析它。

让我们举一个简单的例子,这里有一个联系人对象列表(为了简化,我们称这个类为ContactEntry),与之前的例子一样,它包含姓名、电话号码和电话号码类型字段。这段代码如下所示:

public class ContactEntry {
private String mName;
private String mNumber;
private String mType;
public ContactEntry(String name, String number, int type) {
mName = name;
mNumber = number;
String numType = "";
switch (type) {
case Phone.TYPE_HOME:
numType = "HOME";
break;
case Phone.TYPE_MOBILE:
numType = "MOBILE";
break;
case Phone.TYPE_WORK:
numType = "WORK";
break;
default:
numType = "MOBILE";
break;
}
mType = numType;
}
public String getName() {
return mName;
}
public String getNumber() {
return mNumber;
}
public String getType() {
return mType;
}
}

在这里,你会注意到在ContactEntry的构造函数中,我将整数类型直接转换为了可读的字符串类型。至于实现,我们创建了自己的ContactBaseAdapter类并扩展了BaseAdapter类,使我们能够覆盖getView()方法。

从概念上讲,BaseAdapterCursorAdapter非常相似,区别在于我们传递并保持的是一个对象列表,而不是Cursor。这仅仅是在BaseAdapter的构造函数中完成,此时我们保存了对该对象列表的私有指针,并可以选择围绕该列表编写一系列包装方法(即getCount(), getItem()等)。同样,正如CursorAdapter类知道如何管理和遍历Cursor一样,BaseAdapter类也将知道如何给定的对象列表进行管理和遍历。

重点在于BaseAdaptergetView()方法。注意在CursorAdapter类中,我们既有newView()方法,也有bindView()方法。在这里,我们的getView()方法被设计来扮演这两个角色——实例化新的视图,在行之前为空的情况下,以及将数据绑定到旧的行,在行之前已经被填充的情况下。让我们快速查看代码,并尝试再次连接所有这些片段:

public class ContactBaseAdapter extends BaseAdapter {
// REMEMBER CONTEXT SO THAT IT CAN BE USED TO INFLATE VIEWS
private LayoutInflater mInflater;
// LIST OF CONTACTS
private List<ContactEntry> mItems = new ArrayList<ContactEntry>();
// CONSTRUCTOR OF THE CUSTOM BASE ADAPTER
public ContactBaseAdapter(Context context, List<ContactEntry> items) {
// HERE WE CACHE THE INFLATOR FOR EFFICIENCY
mInflater = LayoutInflater.from(context);
mItems = items;
}
public int getCount() {
return mItems.size();
}
public Object getItem(int position) {
return mItems.get(position);
}
public View getView(int position, View convertView, ViewGroup parent) {
ContactViewHolder holder;
// IF VIEW IS NULL THEN WE NEED TO INSTANTIATE IT BY INFLATING IT - I.E. INITIATING THAT ROWS VIEW IN THE LIST
if (convertView == null) {
convertView = mInflater.inflate(R.layout.list_entry, null);
holder = new ContactViewHolder();
holder.name_entry = (TextView) convertView.findViewById (R.id.name_entry);
holder.number_entry = (TextView) convertView. findViewById(R.id.number_entry);
holder.type_entry = (TextView) convertView.findViewById (R.id.number_type_entry);
convertView.setTag(holder);
} else {
// GET VIEW HOLDER BACK FOR FAST ACCESS TO FIELDS
holder = (ContactViewHolder) convertView.getTag();
}
// EFFICIENTLY BIND DATA WITH HOLDER
ContactEntry c = mItems.get(position);
holder.name_entry.setText(c.getName());
holder.number_entry.setText(c.getNumber());
holder.type_entry.setText(c.getType());
return convertView;
}
static class ContactViewHolder {
TextView name_entry;
TextView number_entry;
TextView type_entry;
}
}

首先,让我们看一下构造函数。注意我使用了之前提到的优化——即在构造函数中只实例化一次LayoutInflater,因为我知道在整个 Activity 中 Context 将保持不变。这样在实际运行 Activity 时,性能会有所提升。

现在,让我们看看这个getView()方法中发生了什么。这个方法的参数是行的位置、行的视图和父视图。我们首先需要检查的是当前行的视图是否为空——这将是在当前行之前没有被实例化时的情况,这种情况发生在当前行第一次出现在用户的屏幕上。如果是这样,那么我们就实例化和膨胀这个行的视图。否则,我们知道我们已经预先膨胀了这行的视图,只需要更新它的字段。

在这里,我们还利用了一个静态的ContactViewHolder类作为缓存。这种方法是由谷歌的 Android 团队推荐的(详情请见developer.android.com/resources/samples/ApiDemos/src/com/example/android/apis/view/List14.html),旨在提高列表的性能。视图的膨胀如下所示:

if (convertView == null) {
convertView = mInflater.inflate(R.layout.list_entry, null);
holder = new ContactViewHolder();
holder.name_entry = (TextView) convertView.findViewById (R.id.name_entry);
holder.number_entry = (TextView) convertView. findViewById(R.id.number_entry);
holder.type_entry = (TextView) convertView.findViewById (R.id.number_type_entry);
convertView.setTag(holder);
} else {
// GET VIEW HOLDER BACK FOR FAST ACCESS TO FIELDS
holder = (ContactViewHolder) convertView.getTag();
}

注意,当视图为空时,视图的膨胀过程相当标准——使用LayoutInflater类并告诉它膨胀哪个 R 布局。然而,一旦视图被膨胀,我们会创建一个ContactViewHolder类的实例,并为每个新膨胀的视图的TextView字段创建指针(在这种情况下——它们同样可以是ImageViews等)。一旦新的ContactViewHolder类完全初始化,我们通过将其设置为当前行的标签来关联它(可以将这个过程视为视图到持有者的映射,其中视图是键,持有者是值)。

如果视图不为空,那么我们只需要获取之前实例化视图的标签(再次,你可以将其视为请求一个键的值)。

一旦我们有了相应的ContactViewHolder,我们可以使用传入的位置获取列表中对应的ContactEntry对象。从那里,我们知道当前行引用的是哪个联系人,因此我们可以挖掘出姓名、号码和电话类型,然后相应地设置它们。

就是这样!让我们看看如何实现我们的ContactBaseAdapter

public class CustomBaseAdapterActivity extends ListActivity {
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.list);
// MAKE QUERY TO CONTACT CONTENTPROVIDER
String[] projections = new String[] { Phone._ID, Phone.DISPLAY_NAME, Phone.NUMBER, Phone.TYPE };
Cursor c = getContentResolver().query(Phone.CONTENT_URI, projections, null, null, null);
startManagingCursor(c);
List<ContactEntry> contacts = new ArrayList<ContactEntry>();
while (c.moveToNext()) {
int nameCol = c.getColumnIndex(Phone.DISPLAY_NAME);
int numCol = c.getColumnIndex(Phone.NUMBER);
int typeCol = c.getColumnIndex(Phone.TYPE);
String name = c.getString(nameCol);
String number = c.getString(numCol);
int type = c.getInt(typeCol);
contacts.add(new ContactEntry(name, number, type));
}
// CREATE ADAPTER USING LIST OF CONTACT OBJECTS
ContactBaseAdapter cAdapter = new ContactBaseAdapter(this, contacts);
// SET THIS ADAPTER AS YOUR LIST ACTIVITY'S ADAPTER
this.setListAdapter(cAdapter);
}
}

对于我们的目的来说,你可以忽略第一部分,因为我们实际上是在查询联系人内容提供者,获取结果Cursor,遍历它,并创建一个ContactEntry对象列表。显然这是愚蠢的,所以在你的实现中,假设你将直接获得一个对象列表。一旦我们有了这个列表,调用就很简单了:

// CREATE ADAPTER USING LIST OF CONTACT OBJECTS
ContactBaseAdapter cAdapter = new ContactBaseAdapter(this, contacts);

运行这段代码的结果与之前示例中的第二个截图完全一样(如预期)。

现在我们已经了解了CursorAdaptersBaseAdapters以及如何用代码实现每一个,让我们后退一步,考虑这两个类的潜在用例。

处理列表交互

在 Android 中,每个ListView的一个常见特性是用户应该经常能够选择列表中的一行,并期待某种附加功能。例如,你可能有一个餐厅列表,选择列表中的特定餐厅会带你到一个更详细的描述页面。这正是ListActivity类派上用场的地方,因为我们可以重写的一个方法是onListItemClick()。这个方法有几个参数,但最重要的是位置参数。

方法的完整声明如下:

@Override
protected void onListItemClick(ListView l, View v, int position, long id) { }

一旦我们有了位置索引,无论底层数据是Cursor还是对象列表,我们都可以使用这个位置索引来检索所需的行/对象。之前CursorAdapter的代码示例如下所示:

@Override
protected void onListItemClick(ListView l, View v, int position, long id) {
super.onListItemClick(l, v, position, id);
Cursor c = (Cursor) cAdapter.getItem(position);
int nameCol = c.getColumnIndex(Phone.DISPLAY_NAME);
int numCol = c.getColumnIndex(Phone.NUMBER);
int typeCol = c.getColumnIndex(Phone.TYPE);
String name = c.getString(nameCol);
String number = c.getString(numCol);
int type = c.getInt(typeCol);
System.out.println("CLICKED ON " + name + " " + number + " " + type);
}

同样,BaseAdapter示例的代码如下:

@Override
protected void onListItemClick(ListView l, View v, int position, long id) {
super.onListItemClick(l, v, position, id);
ContactEntry c = contacts.get(position);
String name = c.getName();
String number = c.getNumber();
String type = c.getType();
System.out.println("CLICKED ON " + name + " " + number + " " + type);
}

两者非常相似且不言自明。我们只需使用位置索引检索所需的行/对象,然后输出所需的字段。通常,开发者可能有一个单独的 Activity,在这里他们向用户提供了他们点击的行中的对象(即餐厅、联系人等)的更多详细信息。这可能需要从ListActivity传递行的 ID(或其他标识符)到新的详细信息 Activity,这是通过将字段嵌入到 Intent 对象中完成的——但这更多内容将在下一章介绍。

比较CursorAdapterBaseAdapter

那么,在什么典型场景下,你会发现自己使用BaseAdapter而不是CursorAdapter,反之亦然?我们之前已经考虑了一些情况,但让我们花更多的时间来头脑风暴一些用例,以使你更加熟悉这两个ListAdapters以及何时在两者之间切换。

通常的规则是,当你的底层数据以Cursor的形式返回时,使用CursorAdapter;当你的数据可以以对象列表的形式返回或操作时,使用BaseAdapter

这意味着对于大多数网络请求,当数据以长字符串的形式返回时(再次,有点提前说,但这个字符串通常会是 XML 或 JSON 格式),最好解析这个字符串并将其转换为对象。然后,这些对象可以存储在列表中,并传递给自定义的BaseAdapter。如果你调用外部 API,通常数据也会以 XML 或 JSON 格式返回,这也是通常的情况。如果想要缓存结果,则是例外情况。

缓存通常涉及在内存中更本地(或更快)的区域临时存储一些数据(对于 CPU 系统,这意味着将数据存储在 RAM 中而不是磁盘上,对于移动应用,这意味着将数据本地存储而不是通过网络持续请求外部数据)。如果你想缓存一些网络调用——无论是出于性能原因还是离线访问原因——那么建议的流程是进行你的网络请求,获取格式化的数据字符串,解析数据字符串,并将数据插入 SQLite 数据库(旨在模仿外部数据库)。然后,由于你的数据已经存储在 SQLite 数据库中,最好(也是最简单)的方法是快速查询并获取一个Cursor

那么,如果你有一个静态的基本对象列表,比如字符串,该怎么办呢?如果你有一个预定义选项列表的固定目录,这通常就是这种情况。在这种情况下,BaseAdapterCursorAdapter都显得过于复杂,你应该选择使用一种更简单的适配器,即ArrayAdapter。我尽量不花时间在这种ListAdapter上,因为它的使用非常简单,概念上也非常简单——如果你有一个静态的字符串数组,并且你想从中创建一个列表,只需将这个数组传递给ArrayAdapter即可。

然而,关于ArrayAdapter我就说这么多,我邀请你阅读以下网站的示例:

developer.android.com/resources/tutorials/views/hello-listview.html

否则,请记住,对于轻量级的静态数据,使用ArrayAdapter;对于动态的面向对象数据,使用BaseAdapter;对于基于本地存储的子表数据,使用CursorAdapter

概述

在本章中,我们最终将焦点从后端转移到了前端——深入了解了我们可以将数据绑定到用户界面的方法。当然,用户可以通过多种方式与数据交互,但迄今为止最常见的方式是通过ListView

ListViewsListActivities是非常方便的类,它们允许我们将ListAdapters绑定到 Activity,进而绑定到列表布局,处理诸如用户触摸列表中某行的事件。ListAdapters是那些接收底层数据并为你处理绑定过程的类——也就是说,当你的列表上下滚动时,你无需跟踪列表中的位置;所有这些工作都在幕后完成。相反,你需要做的就是根据你拥有的底层数据类型选择合适的ListAdapter,并指定你希望如何进行绑定。

配备了这些ListAdapters,我们能够重新创建一个简化版的联系人列表,更重要的是,我们得以初步了解所有将数据以交互式、美观的方式展现出来的方法。

在本章的最后,我们思考了ListAdapters每个子类的使用场景(总共看到了三个不同的子类,分别是CursorAdapterBaseAdapter以及最后的ArrayAdapter),同样地,希望这能够帮助我们在后端和前端应用程序设计过程中建立直觉。

在下一章中,我们将继续进行头脑风暴,尝试将我们所学到的知识整合在一起——通过一些实际例子来走查,并讨论我们如何设计后端和前端来实现这些例子。

第七章:实践中的 Android 数据库

在上一章中,我们最终探讨了将后端数据库绑定到用户界面的方法。在这一点上,我们了解了所有内置在 Android 操作系统中的各种本地存储方法(第一章,在 Android 上存储数据和第二章,使用 SQLite 数据库),尤其是 SQLite 数据库,以及如何利用 SQLite 语言执行强大查询的方法(第三章,SQLite 查询)。此外,我们还知道如何通过内容提供者将自定义 SQLite 数据库暴露给外部应用程序(第四章,使用内容提供者),以及如何查询预存在的内容提供者,如Contacts内容提供者(第五章,查询联系人表)。

在这一点上,我们已经装备了自己大量的工具——足以开始构建完整的应用程序。然而,在我们开始之前,让我们暂停一下并思考一下。

我们真的应该依赖本地SQLite 数据库吗?如果用户的手机出了问题,他们的数据被删除了怎么办?或者更重要的是,每个用户是否需要下载整个数据集,并将其本地存储在手机上?请记住,手机的内存相当有限,仅是台式机内存的一小部分。

当我们开始考虑如何设计我们的应用程序时,所有这些问题都会发挥作用。因此,在本章中,我们将从一些实际的使用场景开始,探讨为您的 Android 应用程序提供本地化 SQLite 数据库的用途,然后转移到针对以数据为中心的应用程序的其他更典型的应用程序设计(如果您的应用程序将是一款游戏,那么这可能不适用)。

本地数据库使用场景

因此,让我们从不同的角度考虑一个 Android 应用程序可能会如何使用本地化的 SQLite 数据库。为了澄清,我所说的本地化的 SQLite 数据库是指仅存在于手机内存中,更具体地是在应用程序分配的内存中,并且没有外部数据库支持/备份的数据库。这与外部数据库形成对比,外部数据库存在于服务器(或云中),可以作为本地数据库的备份,或者作为中央数据库,所有应用程序都会请求、插入、更新和删除数据。

以我们的第一个示例来说,考虑一个基于谜题的应用程序,它跟踪每个级别的用户高分。高分表格将包含诸如该分数的排名(即第一、第二、第三等)、获得该分数的用户名称以及分数本身等字段。让我们逐一考虑每种数据存储形式,思考它是否是完成当前任务的一个合理的方案:

  • SharedPreferences: 我们可以使用基于 Map 的类来完成这个任务吗?我想,如果我们只需要一个高分表格(而不是每个级别一个)并且该表格只有几行数据,我们或许可以使用简单的 Map 来实现。但这可能并不是SharedPreferences类的自然使用方式,而且我们可能通过使用不同的数据存储类型来做得更好,所以现在我们先不考虑这个方案。

  • 外部 SD 卡: 如你所知,写入 SD 卡对于保存和备份文件非常有用。理论上,我们可能通过将表格保存为逗号分隔值(CSV)文件(你可以将这些文件视为电子表格)来进行文件格式的保存。这样,每个级别对应一个 CSV 文件,由于 CSV 文件的格式与电子表格类似,我们可以很容易地将这些文件读取并绑定到类似GridView的控件中。将数据保存到 SD 卡的优点之一是数据自然得到了备份。例如,如果用户由于某种原因需要卸载并重新安装你的应用程序,那些 CSV 文件仍然存在,数据也得到了保留。然而另一方面,如果用户出于某种原因移除了 SD 卡或对 SD 卡进行了篡改,那么数据可能会丢失或损坏。无论如何,使用 CSV 文件和外部 SD 卡并不是一个糟糕的解决方案,但它可能不是最优化或最自然的选择。

  • SQLite 数据库:考虑到我们试图保存一系列表格,自然我们会考虑使用某种数据库架构。现在,根据我们的游戏中包含多少个级别(以及我们可能需要的表格数量),我们可以设计一个数据库架构,每个级别都有一个单独的表格,对于每个级别,我们只需将Cursor指向正确表格的 URI。然而,设想一下如果我们有 50 个级别会怎样。在这种情况下,创建 50 个具有 50 个唯一 URI 的相同表格似乎有点愚蠢。因此,我们可能会在表中增加一个用于表示级别的字段。然后,当我们执行查询时,可以通过级别列过滤表格,并对剩余的子表格按排名进行排序。在这种情况下使用 SQLite 数据库特别方便,因为我们可以将生成的Cursor直接通过ListView绑定到用户界面。那么,这里的问题是什么呢?好吧,如果用户必须卸载你的应用程序,那么你的 SQLite 数据库极有可能从手机内存中被清除。

  • 外部数据库:在这种情况下使用外部数据库可能会变得相当复杂。为什么?首先,我们考虑一下我们的数据模型会是什么样子。可能我们会有一个巨大的表格,包含发出请求的设备(即请求数据的手机号或用户名)、请求的关卡等字段,然后就是包含一堆过滤子句的查询。或者,更好的解决方案可能是为每个关卡设置一个表格,并为每个表格包含一个附加字段,指明哪一行属于哪个设备。如您所见,无论哪种情况,数据模型都会有些混乱,但目前我们还是坚持后一种模式。假设你的游戏表现不错,达到了 10 万的活跃安装量。再假设你的游戏有 50 个关卡,每个高分排行榜都保留前 10 名成绩。对于一个半热门的游戏来说,这并不过分,对吧?在这种情况下,你的外部数据库突然就有了 50 个表格,每个表格有百万行数据,这会导致一个相当庞大且占用内存的数据库。此外,你还需要考虑到,每次用户请求查看一个高分排行榜时,他/她都需要向你的外部数据库发出一个 HTTP 请求,以获取相应的表格。这个 HTTP 请求的速度将远远慢于对本地数据库的简单 SQLite 查询。那么,所有这些工作的优点是什么?这种方法可以备份每个用户的高分记录,无论他们卸载和重新安装应用多少次,或者更换多少次手机等等。另一个很好的特性是,一旦你收集了所有用户的数据,你就可以创建一个全球高分排行榜——让你的用户不仅能看到他们特定 Android 设备上的高分,还能看到所有玩过你游戏用户的史上最高分!

因此,即便在这种情况下,使用本地数据库与外部数据库也各有优缺点。在这种情况下你需要问自己的问题是:

  • 用户的高分记录备份有多重要?

  • 构建一个全球高分排行榜的可能性/实用性有多大?

如果你的目标是打造一款竞争性极强的游戏,并且你认为用户如果重新安装应用或更换手机会非常不满,因为他们会失去高分记录,那么使用外部数据库可能是明智之举。然而,我推测,很少有手机游戏会让用户变得如此竞争激烈,在这种情况下,使用简单的本地数据库将更为实际。

结论是什么?对于一款基于普通谜题的游戏,以及一个简单的高分排行榜,使用本地化数据库就足够了。数据的格式(也就是表格)使得这个数据库成为自然的选择,而且假设用户不会关心他们的高分是否被保存,这使得实现一个本地化数据库比外部数据库更为实际。

在我们继续之前,再考虑一个例子。假设你想创建一个应用程序,使用户能更好地找到咖啡馆和咖啡店。也许你想添加一些功能,允许用户根据空间可用性或 Wi-Fi 可用性筛选咖啡馆和咖啡店(我经常发现自己走进附近的星巴克,结果发现所有的桌子都被占了)。这个应用不错——但你会从哪里找到你的初始咖啡馆/咖啡店数据库呢?

幸运的是,你遇到了一些来自不同服务(如 Yelp、Zagat 等)的 API,它们允许你查询它们的数据库,因此数据源不再是问题。但现在怎么办?你将如何设计你的 Android 应用程序的后端?让我们再次审视我们的选择:

  • SharedPreferences: 这一次,很明显,像SharedPreferences这样简单轻量级的方法并不合适。我们将放弃这个选项。

  • 外部 SD 卡:就像我们之前的例子,使用外部 SD 卡的一个可能方法是,将你的数据存储在 CSV 文件中(即电子表格格式),然后读取和写入这些文件。那么,我们在这里可能会做的是,在第一次进入我们的应用程序时,我们进行一系列的 API 调用,以加载我们初始的咖啡馆/咖啡店数据库。然后我们将数据写入 CSV 文件,并引用/更新这个 CSV 文件。到目前为止,一切顺利。但是当我们想要开始筛选我们的数据时会发生什么?比如用户只想看到他/她附近的地点,或者只想看到提供免费 Wi-Fi 的地点。当处理 CSV 文件时,并不存在查询这个 CSV 文件的概念,因为文件就是文件,我们唯一的解决方案就是打开与文件的连接,遍历每一行,手动挑选出我们想要的行。在这个例子中,尽管这会变得缓慢且繁重,理论上我们可以用这个 SD 卡解决方案实现后端。然而,很容易看出,一旦我们的模式变得更加复杂(需要多个表而不仅仅是一个),不能执行高效、复杂的查询将导致一个极其糟糕的设计决策。更不用说之前提到的一些问题,比如用户移除 SD 卡、SD 卡损坏等。在这种情况下,我们最好还是远离 SD 卡。

  • SQLite 数据库:对于 SQLite 数据库来说,这也是一个自然而然的解决方案,因为我们的数据具有固有的表格格式。我们可以很容易地创建一个包含名称、位置、Wi-Fi 可用性等字段的架构,然后编写一系列查询来快速相应地过滤数据。此外,使用 SQLite 数据库,我们还能够轻松地将数据绑定到用户界面。然而,我们的后端机制会是什么样的呢?用户首次打开应用程序时,我们是否需要访问所有 API 并下载全国所有咖啡馆/咖啡店的全量数据集?如果我们不这样做,那么当用户旅行或想要查看他们当前城市以外的位置时,我们很可能会遇到问题,唯一的解决方案可能是为每个新位置调用 API。如果我们一次性下载整个数据集,那么根据美国咖啡馆/咖啡店的数量,我们可能会遇到内存和性能方面的问题。在两种情况下,我们都需要有计划地选择如何将 SQLite 数据库与通过 API 获取的最新信息同步和更新,这本身就是一个完全不同的问题。

  • 外部数据库:通过使用外部数据库,我们可以利用数据的固有表格格式。与本地数据库一样,我们仍然可以执行快速查询来过滤数据。我们受益于拥有一个集中式数据库,确保每次用户请求数据子集时,获取到的都是最新的数据。此外,由于我们的数据库将位于外部服务器上,因此在应用端不需要额外的内存,并且我们应能显著提升性能,因为访问一个外部数据库远比多次访问多个 API 要快。我们相对于 SQLite 数据库的劣势在于(当用户)反复进行相同请求时会发生的情况。例如,假设用户打开搜索Activity,搜索他/她想要的位置列表,等待几秒钟让网络请求返回,然后意外关闭了那个Activity。如果用户随后重新打开应用程序并返回到该Activity,他/她将需要再次进行相同的网络请求,并等待几秒钟才能获取到相同的结果。这对于活跃用户来说通常是一个巨大的烦恼,并且鉴于许多移动用户相对较短的注意力集中时间,这可能会对应用程序的成功产生致命影响。

现在我们已经了解了我们可以使用的数据存储方法列表,让我们快速总结一下每种方法的优缺点。首先,从纯实现的角度来看,本地数据库和外部数据库是明显的胜者。然后,在内存消耗方面,由于整个数据集可以存在于应用程序之外,外部数据库比本地数据库是更好的选择。在性能方面,外部数据库的好处在于,我们不是访问多个 API,只需访问一个数据库(我们自己的)。然而,本地数据库的好处在于,用户可以在不进行任何额外网络调用的情况下,在搜索Activity中自由进出。

在这里没有明确的胜者,但是有一种方法可以结合这两种方法,设计一个健壮的后端,解决之前讨论的所有问题。这种结合方法使用外部数据库作为中央存储单元,但使用本地数据库作为缓存来提高性能。在下一节中,让我们深入了解使用本地 SQLite 数据库作为外部数据库的缓存而不是独立数据库意味着什么。

数据库作为缓存

那么,缓存究竟是什么呢?缓存通常被定义为内存中的一个位置,用于存储重复数据,以便在将来可以更快地提供服务。在我们的案例中,这正是我们需要的。

在我们之前的示例中,我们看到了通过使用外部数据库,可以在不牺牲实现的前提下,提高内存消耗和性能。此外,我们自然可以确保所有用户拥有相同的数据,且这些数据是最新的。唯一当仅依赖外部数据库会受到影响的情况是,当用户在你的应用程序中操作时,每次都必须对外部数据库进行相同(或相似)的网络请求,并且不得不反复等待这些网络请求返回。

一种解决方案是使用缓存,并且只需进行一次网络请求。然后,在网络请求完成后,将返回数据的副本存储在本地数据库上,这样,如果用户进行相同(或相似)的请求,我们的系统只需进行本地查询,而不是网络查询。

为了帮助您更好地理解底层实现,让我们更详细地看看这个缓存是如何工作的。

用户启动你的搜索Activity并发出请求。假设用户的请求是查找他/她位置三英里内提供免费 Wi-Fi 的咖啡馆和咖啡店。你需要做出一个设计选择:在这种情况下应该缓存多少数据?当然,你可以带着用户的所有期望筛选条件发出请求,只缓存这些结果。但如果用户突然决定不再需要免费 Wi-Fi 呢?或者用户决定放宽搜索条件,想要查找五英里内的所有商店怎么办?

虽然拥有缓存将肯定会提升性能,但真正的收益来自于你的缓存被命中的频率。对于那些熟悉设计缓存的人来说,权衡来自于缓存被命中的频率与其大小之间的平衡。换句话说,在极端情况下,如果你设计的缓存包含了你的整个数据集,那么显然每个请求都会命中缓存,从这个意义上说,你的缓存将非常有效。然而,将整个数据集存储在内存中是不理想的(通常取决于数据库的大小,这往往是不可行的),在这方面缓存就会失败。尝试找到两者之间的良好平衡是目标所在。因此,在这种情况下,为什么不尝试请求五英里内的所有位置,并完全排除 Wi-Fi 筛选器呢?

通过缓存这个请求,当用户决定将搜索条件从三英里放宽到五英里(或者减少到两英里)时,你已经有所有结果了;所以,你不需要发出另一个网络请求,只需简单地过滤缓存以获取所需的数据子集即可。同样,如果用户想要移除 Wi-Fi 筛选器,你可以迅速查询缓存中的数据,这次移除仅限 Wi-Fi的筛选器。在这两种情况下,用户命中了你的缓存,从而为你节省了耗时的网络请求。

设计缓存系统的最后一步就是确定多久刷新一次缓存。从不刷新缓存是不理想的,因为随着时间的推移,每次你缓存新请求,它只会消耗更多的内存,而且,你还会遇到数据过时的问题。例如,假设用户对其家乡的咖啡馆/咖啡店发出请求,你缓存了这个结果。然而,你的缓存系统从不刷新缓存。一年内很多事情都可能发生,一年后用户再次拿出你的应用并发出同样的请求,他/她将会命中缓存并获取旧数据,而不是发出新的请求。

另一方面,如果允许你的缓存太频繁地刷新,你将降低缓存命中频率,最终不得不进行比预期更多的网络请求。因此,我们再次面临一个优化问题,我们希望最大化缓存命中次数,同时最小化所需的内存消耗,并最小化我们拉取陈旧数据的频率。

这个简化的优化问题位于每个缓存系统的核心,当你使用本地数据库来缓存外部数据库网络请求时,这是你需要牢记的问题。尽管关于缓存还有很多可以讨论的内容,但本节(以及整章)的目标是激发你的思考过程,并介绍本地数据库的众多用途之一,以及它们如何与外部数据库结合使用。

在下一节中,我将讨论一个典型的以数据为中心的应用程序的外观,以及典型的数据流程。再次强调,我所说的以数据为中心的应用程序是指那些主要功能涉及显示/与某种形式的数据进行交互的应用程序。这可以包括从社交网络应用程序(用户可以相互阅读/发送消息,这里的数据包括消息、事件、照片——任何可以共享的内容),到餐饮应用程序(用户可以加载附近餐厅的详细信息)。这通常不包括许多基于游戏的应用程序,尽管即使是基于游戏的应用程序有时也需要采用某种外部数据库(例如,我们之前讨论的全局高分表)。接下来,让我们再次调整焦点,从更全面的角度思考移动应用程序——作为外部数据库和外部应用程序的扩展,而不仅仅是简单的独立应用程序。

典型应用程序设计

迄今为止,我们只讨论了关于后端应用程序设计的想法。我们首先考虑了完全本地化的后端与完全外部后端使用的优缺点,然后考虑了在应用程序中使用两者的方法,试图两全其美。我们可以这样做的一种方式是使用缓存,仅在设计缓存时,我们就发现有许多设计决策必须被做出。

不论你是否意识到,这段时间你一直在分析不同应用程序的不同后端设计的优缺点,现在我们准备关注一个非常通用且极其实用的设计,这种设计在以数据为中心的移动应用程序中经常使用。但是关于文字就到此为止,让我们给我们的设计配上图片:

典型应用程序设计

那么这里到底发生了什么?让我们来分解一下:

  1. 首先,我们有我们的外部集中式数据库。这是我们后端的核心。所有应用程序(无论是网页还是移动应用)都将引用这个数据库,这样我们可以确保所有移动设备上的数据都是同步且最新的。此外,在这种设计中,我们的应用程序不再是特定于某个平台的。换句话说,可以轻松创建一个在所有移动设备上都能运行的应用程序,包括 Android 和 iOS,因为所有设备都指向同一个数据库。

  2. 外部数据库还将客户端(即移动应用和网页应用)与数据收集/解析/清洗端分离。在这里,后者包含了所有旨在收集、解析和清洗后端数据的流程。这可能包括定期调用 API(假设 API 允许你存储其数据的副本)、抓取网页(稍后讨论)、或者在有些情况下手动插入新数据。一旦数据进来,通常需要被解析和清洗以符合你的数据库规格。此外,通过使用 CRON 作业(在第九章中讨论的收集和存储数据),整个数据收集和清洗的过程本身也可以自动化。因此,以这种方式设置你的应用程序,你可以将所有这些后台数据挖掘工作从用户面前隐藏起来。

  3. 另一方面,网页应用和移动应用在不断地向你的外部数据库发送请求。这些请求通常以 HTTP GET 和 POST 请求的形式(获取数据与插入/更新数据),并以 XML 或 JSON 格式返回结果。再次强调,由于这些只是标准的 HTTP 网络请求,因此与发起请求的平台无关,你可以轻松地将应用程序从一个平台移植到另一个平台。

  4. 最后,我们有缓存,这是外部数据库的一个临时的、局部的子集,存在于移动/网页应用端。如早前讨论的,这些缓存设计用来通过避免重复的网络请求来提高应用程序的性能。

这就是我们要介绍的内容。再次强调,目前这还是一个很高的层面,但我们已经看到了并讨论了与我们的设计第四部分相关的组件,在接下来的章节中,我们还将看看前三个部分。

总结

尽管在本章中我们没有查看任何代码,但我们仍然完成了很多工作。我们通过确定两个非常现实的需求(一个简单的高分榜和一个位置/场所数据库)开始本章,并走过了选择合适存储方法的思考过程。

我们发现,对于像高分排行榜这样简单的功能,一个本地化的 SQLite 数据库既有效又易于实现。这种方法唯一真正的缺点是无法显示全局高分排行榜,但对于大多数游戏来说,这只是一个小功能。然而,对于我们的咖啡馆/咖啡店应用程序,我们发现本地化的 SQLite 数据库远不如中央外部数据库有效,外部数据库解决方案唯一的缺点是,如果频繁进行重复且不必要的网络调用,性能会受到影响。

为了解决这个问题,我们求助于缓存作为一种解决方案——同时使用外部和本地数据库,试图利用每种方法的优点。然而,要构建一个有效的缓存,需要做出几个设计决策,以优化缓存命中频率,同时最小化内存消耗和陈旧数据。

最后,我们在本章的结尾不仅从代码中抽身,也从 Android 应用程序本身抽身,试图从更全面的角度审视我们的应用程序。我们探讨了典型的以数据为中心的应用程序的外观,并将数据循环分解为四个部分。至此,我们已经涵盖了足够的内容,能够实现设计部分的第四部分(本地缓存),接下来我们将用一章的篇幅来讲解剩余的三个部分。通过本书的学习,我们的目标是让您能够自信地设计和实现一个完整规模的数据中心应用程序。

第八章:探索外部数据库

在上一章中,我们介绍了从完全局限于 Android 客户端的数据库,转向使用外部数据库的概念,这在我们整个开发过程中可以以多种方式提供帮助。

我们已经看到,通过使用外部数据库,我们能够在 Android 应用程序中改善内存使用(特别是,不必存储非常大的数据库文件),同时通过使用缓存而没有牺牲太多性能。此外,我们还了解到,使用外部数据库允许我们备份用户数据(以防用户更换手机或卸载你的应用程序),防止用户看到过时的数据(因为所有数据都存在于一个中央位置),以及可能查看其他用户的数据(记得全球高分示例)。

使用可以通过网络与应用程序通信的外部数据库,将使你成为更加多才多艺的应用程序开发者,并为你提供创建完全可扩展的数据中心应用程序的工具。

不同的外部数据库

那么到底有哪些类型的外部数据库呢?正如 Android、iOS、Palm 等操作系统都允许你开发移动应用一样,目前有几个容易访问的平台允许你托管和开发外部数据库。

其中一个“平台”就是设置一个具有数据库功能的传统专用服务器。例如,这种方式的流行例子是使用专用计算机托管连接到MySQL数据库的Apache Tomcat服务器。我不会详细介绍如何设置这种服务器-数据库连接(主要是因为你可以用无数种方式来做),但让我们先考虑一下高级概念,然后再来看一个简单的优缺点列表。

在高层次上,Apache Tomcat 服务器充当了一个中介,处理所有的进出的 HTTP 请求(即网络请求)。服务器本身监听所有这些传入的请求,并在接收到请求时,有代码告诉它如何处理请求以及随后返回什么作为响应。处理请求并返回响应的代码通常被称为HTTP 服务端小程序,在接下来的章节中,我们将实际实现一些这样的小程序,以便让你更好地了解它们是如何工作的。

然而,Apache Tomcat 服务器还通过Java 数据库连接驱动(JDBC)连接到 MySQL 数据库。一旦配置好,这将允许我们处理传入的 HTTP 请求,这些请求然后告诉服务器向 MySQL 数据库发出查询。一旦 MySQL 数据库检索到查询,它将执行它并返回所需的数据,最终发送回原始请求者。

使用这种模式,优点是它完全可定制,你可以完全控制每个部分的实现方式。然而,这可以是一把双刃剑,是好是坏取决于谁在处理服务器和数据库。关注数据库部分,由于它完全可定制,我们可以完全控制我们想要使用的数据库管理系统(DBMS),甚至进一步控制我们的数据库架构应该是什么样的,以满足给定的数据库管理系统。在整个应用程序开发过程中,如果我们认为有必要,我们甚至可以选择更换我们的 DBMS 或更改我们的架构——例如,如果我们需要一个更具可扩展性的 DBMS。

问题就出在这里。虽然 MySQL 到目前为止是全球使用最广泛的数据库管理系统,在大多数情况下都能出色完成任务,但它并非设计为极致可扩展的。因此,对于大型、数据密集型的应用程序来说,使用 MySQL 可能是一个次优的设计决策。回到我最初的观点,即使用完全可定制的服务器和数据库可能是一把双刃剑,可以很容易看出在这种情况下灵活性和责任感是如何并存的。当我们获得系统设计和实施的更多灵活性时,同时我们也承担着更多关于做出明智设计决策的责任——否则,我们的应用程序性能可能会迅速恶化(即想象一下,如果所有谷歌的数据都托管在一台计算机上——那将是一场噩梦)。

其他缺点是,这些系统通常需要更高的初始成本,因为我们需要实际购买计算机/服务器。此外,由于这些计算机/服务器容易发生故障,我们将不得不定期管理它们,以确保它们不会崩溃。由于它们的灵活性,许多公司和初创企业选择这种模式,尽管许多公司最终会雇佣专门负责维护这些服务器以及后端开发人员专门构建这些服务器和数据库的专家。

然而,近年来云计算的概念越来越受欢迎,这里我将介绍两个这样的平台。第一个是 亚马逊网络服务(AWS),它提供了一系列云计算服务,特别是 亚马逊弹性计算云(EC2)亚马逊关系数据库服务(RDS)。这两者之间的主要区别在于,亚马逊的 EC2 被设计成一个功能齐全、完全虚拟的计算环境,允许你控制尽可能多的服务器/数据库实例(从而使其具有固有的可扩展性)。而亚马逊的 RDS,则被设计成仅作为一个云数据库,尽管该服务包含了一些使你能够选择扩展计算和存储能力的功能。因此,根据你的应用程序,你可以选择最合适的服务。亚马逊的计算服务现在被许多公司使用,包括 Yelp、Reddit、Quora、FourSquare、Hootsuite 等知名初创公司,在设计任何未来后端时,这绝对是一个值得考虑的因素。

另一个云计算服务是 谷歌的应用引擎(GAE),我们会更深入地了解它。AWS 和 GAE 都容易设置(相对于传统服务器方法),而 GAE 以更用户友好而闻名。然而,我们之所以要关注 GAE 而非 AWS(除了这是一本现在以 Google 为主题的书之外)的主要原因是,GAE 允许你免费运行小规模的应用程序(直至某些预定义的限制),而 AWS 只允许你在一年内访问其免费定价层。这样,每个人都能在后续章节中跟随我们查看更多代码。

最后,传统服务器/数据库模型与新的云计算模型之间的区别在于,我们实际上不需要拥有和管理专用的服务器!这些云计算服务允许我们基本上在亚马逊/谷歌的各个数据中心内“租用”服务器空间,并允许我们快速/低成本地创建可靠、可扩展的应用程序。然而,我们放弃的是在实施过程中的一些控制和灵活性,在下一节我们讨论 Google App Engine 的 Java 数据对象 (JDO) 数据库时,我会进一步阐述这一点。

Google App Engine 和 JDO 数据库

那么,Google App Engine 究竟是什么,我们为什么需要它呢?其实,GAE 是一个平台,它允许你在支撑 Google 应用程序的同一天然系统上构建和托管网络应用。GAE 使我们能够快速开发部署应用程序,而无需担心可靠性、可扩展性、硬件、补丁或备份等问题。然而,这种可靠性和可扩展性是有代价的,那就是我们在选择 DBMS 和设计数据库架构时的灵活性。实际上,当你选择使用 GAE 作为后端时,这两者基本上都是为你预先选好的!

GAE 附带了一个 JDO 数据库——这意味着它带有一个特殊的数据库,允许你将 Java 对象直接转换为称为实体(因此得名)的数据行。这个 JDO 数据库构建在一个名为 BigTable 的特殊网络数据库之上,它旨在实现极快和可扩展,实际上并不是像 MySQL 那样的关系型数据库管理系统(见en.wikipedia.org/wiki/BigTable))。这主要意味着我们在第三章中学习的关于 SQL(即 JOINS)的所有功能在这里并不都适用,因此关于你的数据库架构应该如何设计,你的决策会有一定的限制。

鉴于此,谷歌提供了一种名为GQL的 SQL 变体,这是一种查询语言,专为从 App Engine 可扩展数据存储中检索实体而设计。同样,这里有一些差异,但 GQL 的一般感觉与 SQL 非常相似:你有带有WHERE筛选器的SELECT语句,以及其他熟悉的子句,如ORDER BYLIMIT。这样,对于那些只熟悉像 MySQL 这样的关系系统的用户来说,学习起来应该不会太困难。

为了完整性起见,其他差异包括在不建立索引的情况下无法基于多个条件进行筛选,无法在同一个查询中对多个列使用不等式筛选器,以及无法筛选缺少字段的行等。所有这些看似任意的差异的原因都涉及到 BigTable 数据库的架构。由于它的设计方式以及它为每插入的行建立索引的方式,像 MySQL 这样的关系数据库中可用的某些查询将不再适用于 BigTable。然而,正是由于这种架构,BigTable 本质上是可扩展的,因此在选择两者之间时,请记住这些权衡。

无论如何,语言只能带你走到这么远,一旦我们开始看到一些实际的代码,所有这些差异和相似性将会变得更加清晰。因此,除了安装 Android SDK 之外,我建议你花些时间通过以下 URL 指南来搭建 Google App Engine:

code.google.com/appengine/downloads.html#Download_the_Google_App_Engine_SDK

在这一点上,我们已经准备好直接深入一些代码,尝试为我们的 Android 应用程序拼凑一个完全功能的 Google App Engine 后端!

GAE:以视频游戏为例

在接下来的几章中,我们将通过一个例子来学习如何创建一个应用程序,以查看通过 Blockbuster 可以获取哪些视频游戏。这将涉及到从编写爬虫来从 Blockbuster 的网站获取和检索这些视频游戏,将游戏对象存储到我们的 GAE 数据库中,编写 servlet 通过 HTTP 请求从我们的 GAE 数据库中获取/删除游戏对象,最后但同样重要的是,完成一些适用于 Android 客户端的代码。

在本章中,我们将重点介绍如何设置数据库并编写包装方法,以帮助我们存储、检索、更新和删除数据,为后续步骤做准备。首先,每个 GAE 应用程序都需要定义一个基本实体类,这个类本质上定义了数据库中的行。请注意,每个实体都需要有一个与之关联的 ID 或键,所以我们真正需要的只是一个 ID 字段。下面是ModelBase类,我们将它用作我们的基本实体类,并对我们创建的所有对象进行重写:

@PersistenceCapable(detachable = "true")
@Inheritance(strategy = InheritanceStrategy.SUBCLASS_TABLE)
public class ModelBase {
@PrimaryKey
@Persistent(valueStrategy = IdGeneratorStrategy.IDENTITY)
private Long id;
public Long getId() {
return id;
}
}

我们会注意到这个类的总体结构类似于相对简单的 Java 对象,但有一些奇怪的@标签。让我们先看看前两个:

@PersistenceCapable(detachable = "true")
@Inheritance(strategy = InheritanceStrategy.SUBCLASS_TABLE)

第一点告诉我们,这个类需要是PersistenceCapable。当你定义一个对象为可持久化的,你其实是在告诉 JDO 数据库,这个对象能够从数据存储中存储和检索。声明实体类为PersistenceCapable并声明所需的字段为Persistent是非常重要的。你会看到还有一个名为detachable的参数,我们将其设置为true。这使得我们有权在关闭数据库后编辑和修改从数据库中检索的实体。现在,这并不意味着这些修改会在数据库中持久化,因为数据库已经关闭,但至少我们有权限这样做。

接下来是Inheritance标签,这意味着我们允许创建覆盖这个基础实体的实体,从而继承基础实体。另外两个标签相当容易理解。第一个声明我们的 ID(我快速说明一下,在我的例子中我选择使用 long 类型的 ID,但也可以使用 Key 类型对象)作为我们实体的PrimaryKey。对于有 SQL 背景的人来说,这应该立即就能明白,但这基本上就是告诉 JDO 数据库,这个实体的对象将有一个唯一的 long ID 字段,用于查找等操作。最后一个标签是我们之前简要提到的一个——即Persistent标签,它只是告诉我们这个 long ID 字段应该作为我们表中自己的列存储。

现在,对于实际的VideoGame对象,首先注意我们是如何扩展(继承)之前的ModelBase类,然后继续定义所有期望的持久化字段,并实现构造函数等,如下所示:

// NOTE HOW WE DECLARE OUR OBJECT AS PERSISTENCE CAPABLE
@PersistenceCapable
public class VideoGame extends ModelBase {
// NOTE THE PERSISTENT TAGS
@Persistent
private String name;
// USE A SPECIAL GOOGLE APP ENGINE LINK CLASS FOR URLS
@Persistent
private Link imgUrl;
@Persistent
private int consoleType;
public VideoGame(String name, String url, String consoleType) {
this.name = name;
this.imgUrl = new Link(url);
// CONVERT ALL CONSOLES TO INTEGER TYPES
this.consoleType = VideoGameConsole.convertStringToInt(consoleType);
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public Link getImgUrl() {
return imgUrl;
}
public void setImgUrl(Link imgUrl) {
this.imgUrl = imgUrl;
}
public int getConsoleType() {
return consoleType;
}
public void setConsoleType(int consoleType) {
this.consoleType = consoleType;
}
public static class VideoGameConsole {
public static final String XBOX = "Xbox";
public static final String PS3 = "Ps3";
public static final String WII = "Wii";
public static final String PSP = "Psp";
public static final String DS = "NintendoDS";
public static final String PS2 = "Ps2";
public static final String[] CATEGORIES = { "Xbox", "Ps3", "Wii", "Psp", "NintendoDS", "Ps2" };
public static int convertStringToInt(String type) {
if (type == null) { return -1; }
if (type.equalsIgnoreCase(XBOX)) {
return 0;
} else if (type.equalsIgnoreCase(PS3)) {
return 1;
} else if (type.equalsIgnoreCase(PS2)) {
return 2;
} else if (type.equalsIgnoreCase(PSP)) {
return 3;
} else if (type.equals(WII)) {
return 4;
} else if (type.equals(DS)) {
return 5;
} else {
return -1;
}
}
}
}

一旦你理解了@标签的作用,其余部分就相当容易理解了。这里我只是声明了几个字段为持久化字段,然后实现了一个构造函数以及一个方便的内部类。我喜欢有一个便捷类(在这个例子中是VideoGameConsole)的原因是,在表中,查询整数通常比查询字符串更有效且更可靠(一方面:你不需要担心大小写匹配,另一方面:整数比较通常比字符串比较更有效)。因此,理想情况下,我希望能有一种方法将字符串转换为整数,甚至可能将一组字符串映射到一个整数(例如,“PS3”可以映射到 1,同样“Playstation 3”或“PS 3”也可以)。

既然我们已经定义了VideoGame实体,我们就可以开始实现数据库,并告诉它如何与这些VideoGame实体进行交互。

持久化管理器和查询

第一步是定义一种方法,以建立服务器与数据库之间的连接。回想一下在本书开始时,我们在进行任何查询之前必须调用如getWritableDatabase()这样的方法?在这里也是一样的,但我们不是使用SQLiteOpenHelper类,而是定义一个PersistenceManager类,如下所示:

public final class PMF {
private static final PersistenceManagerFactory pmfInstance = JDOHelper.getPersistenceManagerFactory("transactions-optional");
private PMF() {
}
public static PersistenceManagerFactory get() {
return pmfInstance;
}
}

注意它被定义为单例以提高效率,我们所做的一切就是打开一个可以处理事务(查询)的持久化(数据库)管理器。然后在我们的未来查询中,我们不再需要通过重复请求PersistenceManager来牺牲性能,而是可以直接获取现有实例。

一旦我们定义了PersistenceManager,我们就可以开始实现一系列包装器,并且我们将从如何插入新的游戏对象开始看起:

public class VideoGameJDOWrapper {
/**
* INSERT A SINGLE VIDEOGAME OBJECT
*
* @param g
* - a video game object
*/
public static void insertGame(VideoGame g) {
PersistenceManager pm = PMF.get().getPersistenceManager();
try {
pm.makePersistent(g);
} finally {
pm.close();
}
}
/**
* INSERT MULTIPLE VIDEOGAME OBJECTS - MORE EFFICIENT METHOD
*
* @param games
* - a list of video game objects
*/
public static void batchInsertGames(List<VideoGame> games) {
PersistenceManager pm = PMF.get().getPersistenceManager();
try {
// ONLY NEED TO RETRIEVE AND USE PERSISTENCEMANAGER ONCE
pm.makePersistentAll(games);
} finally {
pm.close();
}
}
}

不错吧?这个概念很简单,我们之前已经见过,只需获取PersistenceManager的实例(即与数据库的连接)并使传入的VideoGame对象持久化。再次提醒,在使用 GAE 时,持久化的概念与插入相同,因此通过使对象持久化,我们实际上是在告诉数据库将我们的实体转换成VideoGame表的一行。我们还可以看到,当一次添加多个实体时,GAE 通过使用批量插入为我们提供了一个高效的实现方式。现在让我们看看如何从数据库中获取视频游戏对象。查询实体比简单插入实体要复杂得多,但与其像第三章那样专门用一整章介绍所有不同的查询方式,我只想展示一种方便直观的方法,如果你好奇,我邀请你查看:

code.google.com/appengine/docs/java/datastore/queries.html(链接内容不需翻译,保留英文)

但是,以下是实现方式之一,它应该会让你想起我们之前遇到的SQLiteQueryBuilder类:

public class VideoGameJDOWrapper {
public static void insertGame(VideoGame g) {
. . .
}
public static void batchInsertGames(List<VideoGame> games) {
. . .
}
/**
* GET ALL VIDEO GAMES OF A CERTAIN PLATFORM
*
* @param platform
* - desired platform of games
* @return
*/
public static List<VideoGame> getGamesByType(String platform) {
PersistenceManager pm = PMF.get().getPersistenceManager();
// CONVERT STRING OF PLATFORM TO INT TYPE
int type = VideoGameConsole.convertStringToInt(platform);
// INIT A NEW QUERY AND SPECIFY THE OBJECT TYPE
Query query = pm.newQuery(VideoGame.class);
// SET THE FILTER - EQUIVALENT TO SQL WHERE FILTER
query.setFilter("consoleType == inputType");
// TELL THE QUERY WHAT PARAMETERS YOU WILL SEND
query.declareParameters("int inputType");
List<VideoGame> ret = null;
try {
// EXECUTE QUERY WITH PARAMETERS
ret = (List<VideoGame>) query.execute(type);
} finally {
// CLOSE THE QUERY AT THE END
query.closeAll();
}
return ret;
}
/**
* GET ALL VIDEO GAMES OF A GIVEN PLATFORM WITH A LIMIT ON THE NUMBER OF
* RESULTS
*
* @param platform
* - desired platform of games
* @param limit
* - max number of results to return
* @return
*/
public static List<VideoGame> getGamesByTypeWithLimit (String platform, int limit) {
int type = VideoGameConsole.convertStringToInt(platform);
PersistenceManager pm = PMF.get().getPersistenceManager();
Query query = pm.newQuery(VideoGame.class);
query.setFilter("consoleType == inputType");
query.declareParameters("int inputType");
// SAME QUERY AS ABOVE BUT THIS TIME SET A MAX RETURN LIMIT
query.setRange(0, limit);
List<VideoGame> ret = null;
try {
ret = (List<VideoGame>) query.execute(type);
} finally {
query.closeAll();
}
return ret;
}
/**
* QUICKEST WAY TO RETRIEVE OBJECT IF YOU HAVE THE ID
*
* @param id
* - row id of the object
* @return
*/
public static VideoGame getVideoGamesById(long id) {
PersistenceManager pm = PMF.get().getPersistenceManager();
return (VideoGame) pm.getObjectById(VideoGame.class, id);
}
}

让我们逐块分析第一种方法:

PersistenceManager pm = PMF.get().getPersistenceManager();
// CONVERT STRING OF PLATFORM TO INT TYPE
int type = VideoGameConsole.convertStringToInt(platform);
// INIT A NEW QUERY AND SPECIFY THE OBJECT TYPE
Query query = pm.newQuery(VideoGame.class);

我们首先获取PersistenceManager实例,然后将传入的平台转换为整型,因为我们将按平台进行过滤。接下来,我们告诉PersistenceManager我们要打开一个新的查询(即开始一个新的SELECT语句),因此我们调用了newQuery()方法。然后,我们使用以下方法设置查询的详细信息:

// SET THE FILTER - EQUIVALENT TO SQL WHERE FILTER
query.setFilter("consoleType == inputType");
// TELL THE QUERY WHAT PARAMETERS YOU WILL SEND
query.declareParameters("int inputType");

这里我们首先设置过滤器,并指定想要执行过滤的列(即设置查询的WHERE部分)。接下来,我们为将要传递的参数设置一个占位符(回想一下之前的?占位符),最后,我们执行查询并传递平台类型参数。在下面这个方法中,除了增加了一个LIMIT过滤器外,其他都保持不变,该过滤器通过以下方法设置:

query.setRange(0, limit);

我们实现的第三种方法相对直接——JDO 数据库允许你通过调用PersistenceManagergetObjectById()方法,快速检索具有唯一键或 ID 的实体。同样,在 GAE 中执行查询的方法有很多,以及许多其他子句和细微差别,本书将不展开讨论,但现在你应该掌握了基本概念,并应该能够执行绝大多数需要的查询。最后,让我们看看如何从数据库中更新和删除对象:

public class VideoGameJDOWrapper {
public static void insertGame(VideoGame g) {
}
public static void batchInsertGames(List<VideoGame> games) {
}
public static List<VideoGame> getGamesByType(String platform) {
}
public static List<VideoGame> getGamesByTypeWithLimit (String platform, int limit) {
. . .
}
public static VideoGame getVideoGamesById(long id) {
. . .
}
/**
* METHOD FOR UPDATING THE NAME OF A VIDEO GAME
*
* @param newName
* - new name of the game
* @param id
* - the row id of the object
* @return
*/
public static boolean updateVideoGameName(String newName, long id) {
PersistenceManager pm = PMF.get().getPersistenceManager();
boolean success = false;
try {
// AS LONG AS PERSISTENCE MANAGER IS OPEN THEN ANY CHANGES TO OBJECT
// WILL AUTOMATICALLY GET UPDATED AND STORED
VideoGame v = (VideoGame) pm.getObjectById(VideoGame. class, id);
if (v != null) {
// KEEP PERSISTENCEMANAGER OPEN
v.setName(newName);
success = true;
}
} catch (JDOObjectNotFoundException e) {
e.printStackTrace();
success = false;
} finally {
// ONCE CHANGES ARE MADE - CLOSE MANAGER
pm.close();
}
return success;
}
/**
* DELETE ALL GAMES OF A CERTAIN PLATFORM
*
* @param platform
* - specify the platform of the games you want to delete
*/
public static void deleteGamesByType(String platform) {
PersistenceManager pm = PMF.get().getPersistenceManager();
int type = VideoGameConsole.convertStringToInt(platform);
// INIT QUERY AGAIN
Query query = pm.newQuery(VideoGame.class);
// SAME WHERE FILTERS
query.setFilter("consoleType == inputType");
query.declareParameters("int inputType");
// NOW CALL THE DELETE METHOD
query.deletePersistentAll(type);
}
}

再次,我们以第一种方法——更新方法为例,逐块分析:

PersistenceManager pm = PMF.get().getPersistenceManager();
boolean success = false;
try {
VideoGame v = (VideoGame) pm.getObjectById(VideoGame.class, id);
if (v != null) {
// KEEP PERSISTENCEMANAGER OPEN
v.setName(newName);
success = true;
}
}

与之前的示例一样,我们首先与 JDO 数据库建立连接。然后尝试通过调用getObjectById()方法并传入我们想要更新的实体的唯一 ID 来检索我们的VideoGame对象。以下是关于PersistenceManager的一件你应该记住的奇怪事情。

与我们现在习惯看到的显式更新方法不同,只要连接打开,对对象所做的任何更改都会自动在数据库中更新。因此请注意,在这个方法中,第一步是检索实体,在连接仍然打开时更新它,然后在实体更新后关闭连接。

当然,在这个例子中,我们一次只更新一个特定的 ID,但可以想象,如果我们牢记这个细节,就可以轻松编写一个同时更新一组实体的方法——只需查询它们的一列表,并在PersistenceManager仍然打开的情况下逐一更新。

最后但并非最不重要的是,对于我们的删除方法,我们看到除了最后一行使用方法之外,所有步骤都与之前的 get 方法相同:

// NOW CALL THE DELETE METHOD
query.deletePersistentAll(type);

否则,所有之前的逻辑保持不变。就是这样!现在我们有一个 JDO 数据库包装类,它让我们可以抽象出所有混乱的PersistenceManager语法,并为我们提供了一种快速插入、检索、更新和删除 GAE 后端数据的方法!下一步是实际找出获取这个视频游戏数据的方法,到那时我们可以简单地将它包装在我们的VideoGame实体类中,并将其推送到我们的数据库。

总结

在本章中,我们离开了 Android 平台,开始扩展对外部数据库的理解。我们首先简要地了解了我们的选择:传统的专用服务器与数据库连接(例如,将 Apache Tomcat 服务器连接到 MySQL 服务器)或云计算服务器/数据库组合,如亚马逊网络服务(AWS)谷歌应用引擎(GAE)

谷歌应用引擎的好处在于,它更容易设置,并允许我们构建简单、相对小规模的应用程序,不受成本和时间限制。这两种云计算解决方案都配备了可靠的服务器以及高效、可扩展的数据库,但限制了你对后端的控制程度——特别是与购买自己的专用服务器时拥有的无限自由相比。

我们继续使用 GAE,开始构建一个简单的视频游戏应用程序,显示我们通过 Blockbuster 可以获得的所有游戏。我们引入了 GAE 中持久化的概念,并编写了我们的第一个实体类。然后,我们编写了自己的PersistenceManager单例类,并实现了一个方便的类,用于从我们的数据库获取、插入、更新和删除数据。

我们在本章中涉及了很多内容,但要在拥有一个完整、完全可用的应用程序之前,我们还有很长的路要走。在下一章中,我们将探讨如何使用本章编写的包装方法来检索数据并将其存储起来。

第九章:数据收集与存储

我们继续前进!在上一章中,我们介绍了一些你可以使用的外部数据库,并决定使用谷歌的应用引擎(GAE)开发一个功能齐全的后端。我们在 GAE 上成功创建了一个新项目,并使用PersistenceManager构建了一个非常实用的包装类,该包装类展示了我们 JDO 数据库中的一些核心概念。当我们开始插入实际数据,并随后使用我们的 Android 应用程序查询这些数据时,这个包装类将非常方便。

接下来就是这里——下一步!对于大多数试图构建以数据为中心的应用程序的人来说,实际获取这些数据将极其困难,通常需要大量的时间和金钱。然而,我们现在有很多工具和方法可以帮助我们利用现有数据来填充我们的数据库。在接下来的章节中,我们将研究其中一些方法,并最终将我们新获取的数据插入到 JDO 数据库中。

数据收集方法

首先,让我们简要回顾一下你可以收集数据的两种不同方式:

  • 通过应用程序编程接口(API)

  • 通过网络抓取

第一种也是最简单的方式是使用 API。对于那些以前从未使用过 API 的人,可以将这看作是由第三方公司创建的网络图书馆,通常允许你调用一些函数(几乎总是以 HTTP 请求的形式执行),从而访问他们数据的一个子集。

例如,一个常见的 API 是 Facebook Graph API,当通过验证后,它允许你查询用户的个人资料信息或事件的详情等。本质上,通过 API,我可以访问到在 Facebook 网站上能看到的人或事件的相同数据,只是通过不同的渠道。这就是我所说的公司公开其数据的一个子集。另一个例子可能是 Yelp,它的 API 允许你通过传递一组参数(即位置)来查询餐馆和场所。在这里,即使我实际上没有在 Yelp 的网页上,我仍然可以通过他们的 API 访问到他们的数据。

拥有一个 API 来收集你的数据非常有用,因为数据已经存在并准备好供你使用;根据公司的信誉,通常数据已经被清理并格式化得很好。这让你不必自己寻找数据,然后自己清理数据。然而,问题是,通常公司出于版权原因不允许你存储他们的数据,所以根据你的应用程序的用途,你可能需要考虑这个法律问题。

那么,如果没有可用的 API 供你使用,会发生什么呢?这时,你可能需要自己获取数据,而进行网络爬虫是完成这项任务的一种好方法。在下一节中,我会花大量时间解释网络爬虫的艺术以及如何进行爬虫操作。现在,让我们以讨论 API 经常返回数据的两种流行格式来结束这个简短的部分。

第一种是可扩展标记语言(XML),它是一种可读性强、以树形结构呈现的数据格式,实际上与 HTML 非常相似。举个例子,如果你调用 Facebook 图形 API,它会返回你的好友列表。这棵树的根可能有一个标签<friends>,下面可能有一系列的叶子标签<friend>。然后,每个<friend>节点可能会分支出几个描述符标签,如<name>, <age>等等。实际上,在后面的例子中,我会使用 XML 作为首选的数据格式,因为它易于阅读,这样你可以看到这种格式的真实例子。

下一种是JavaScript 对象表示法(JSON),它比 XML 更轻量级。JSON 仍然可以被机器读取,但不如 XML 适合人类阅读。然而,其优点是解析 JSON 通常更高效,因此选择使用哪种格式实际上取决于人类可读性相对于性能的重要性。JSON 的一般结构类似于映射而非树形结构。使用前面的例子,而不是返回以<friends>为根节点的树形结构,我们可能会得到一个以friends为键,值为 JSON 数组的结构。该 JSON 数组将包含一系列的friend键,每个键的值都是一个 JSON 对象。最后,JSON 对象将包含等于name, age等键。换句话说,你可以将 JSON 结构看作是一系列嵌套的映射,很多时候键将指向一个子映射,该映射又有自己的键,依此类推。

当你使用第三方 API 时,通常需要知道它们选择以哪种数据格式返回数据,并相应地解析结果。此外,即使在你实现网络爬虫并需要构建自己的 API 时,通常也最好选择两种数据格式之一并坚持使用。这样,在从外部应用程序调用你的 API 并解析返回结果时,你的生活会简单得多。现在,让我们来谈谈网络爬虫。

网络爬虫入门

网页抓取是将网页 HTML 结构化,并系统地从中解析数据的艺术。这个想法是 HTML 应该在一定程度上固有地具有良好的结构,因为每个开放标签(即<font>)都应该有一个关闭标签(即</font>)相对应。这样,如果 HTML 结构正确,它可以被视为一个树状结构,非常类似于 XML。抓取一个网站可以通过多种方式实现,这通常与底层 HTML 源代码的复杂性有关,但在高层次上,它涉及三个步骤:

  1. 获取所需的 URL,建立与 URL 的连接,并获取其源代码。

  2. 组织和清理底层源代码,使其成为一个有效的 XML 文档。

  3. 运行像 XPath(或 XQuery 和 XSLT)这样的树遍历语言,以及/或使用正则表达式(REGEX)来解析所需的节点。

第一步相对容易理解,但我需要指出一点。通常你会发现需要抓取某种动态网页,这意味着 URL 不是静态的,可能会根据日期、一组标准等而变化。让我们通过两个例子来解释我的意思。

第一个涉及股票。假设你正在尝试编写一个可以抓取给定股票当前价格的网页抓取器,比如从 Yahoo! Finance 获取。那么,首先,URL 是什么样子的?快速检查谷歌(股票代码为 GOOG)的当前价格,我们看到相应网页的 URL 是:

finance.yahoo.com/q?s=GOOG

这是一个相当简单的 URL,我们会很快注意到股票代码作为参数传递给 URL。在这种情况下,参数的键为s,值等于股票代码。现在我们可以很容易地看出如何快速构建一个动态 URL 来解决问题——我们只需要编写以下简单的函数:

public void stockScraper(String ticker) {
String URL_BASE = "http://finance.yahoo.com/q?s=";
String STOCK_URL = URL_BASE + ticker;
// CONTINUE SCRAPING STOCK_URL
}

很整洁,对吧?现在假设我们不仅仅想要当前的股票价格,我们还想要获取两个日期之间的所有历史价格。首先,让我们看看一个示例 URL 是什么样的,再次以谷歌股票为例:

finance.yahoo.com/q/hp?s=GOOG&a=07&b=19&c=2004&d=02&e=14&f=2012

那么我们在这里注意到了什么?我们注意到股票代码仍然作为参数传递,键为s,除此之外我们还注意到似乎有两个不同的日期被传递,带有各种键。日期看起来像 07/19/2004,很可能是开始日期,以及 02/14/2012,看起来是结束日期,它们似乎有键值af。在这种情况下,键值并不是最直观的,而且很多时候你会看到键值为dayd以及monthm。然而,这个想法仅仅是通过这个 URL,你不仅可以动态调整股票代码,还可以根据用户需要查看的日期范围来调整这些日期。牢记这个想法,你会逐渐学会如何更好地解读各种 URL,并学会如何使它们极具动态性,适合你的抓取需求。

现在,一些网站通过 POST 请求发送请求。不同之处在于,在 POST 请求中,参数嵌入在请求中(而不是嵌入在 URL 中)。这样,潜在的私人数据就不会在 URL 中明显显示(尽管这只是使用 POST 请求的一个用例)。那么当这种情况发生时,我们应该怎么做呢?嗯,没有特别简单的方法。通常,你需要下载一个 HTTP 请求监听器(对于像 Chrome 和 Firefox 这样的浏览器,只需搜索 HTTP 请求监听器插件)。这将允许你看到正在进行的请求(包括 GET 和 POST 请求),以及传递的参数。一旦你知道了参数是什么,其余的工作就像 GET 请求一样进行。

现在,当我们有了 URL 之后,下一步就是获取底层的源代码并将其结构化。当然,自己来做这件事可能会很痛苦,但幸运的是,有一些库可以帮助我们清理和结构化源代码。我经常使用的一个库叫做HtmlCleaner,可以在以下 URL 找到:

htmlcleaner.sourceforge.net/

这是一个很棒的库,它提供了清理和结构化源代码的方法,导航生成的 XML 文档,最终解析 XML 节点的值和属性。一旦我们的数据被清理,最后一步就是简单地遍历树并挑选出我们想要的数据片段。现在,说起来容易做起来难,因为仅使用 Java 及其本地包并没有真正简单的方法来系统地和可靠地遍历树。我所说的系统性和可靠性是指即使底层源代码的结构稍微有所变化,也能够遍历树并解析正确的数据。

例如,假设你的解析方法简单到告诉代码获取第五个节点的值。那么当 Yahoo!(或你正在抓取的任何网站)决定在其网站上添加一个新标题,现在第五个节点变成了第六个节点时,会发生什么?即使在这种对底层网站的相对简单的更改下,你的爬虫也会崩溃,并开始从错误的节点返回值,因此,我们理想上希望找到一种方法,无论底层网站如何变化,都能获取正确的节点值。

幸运的是,前端工程师经常会构建这样的网站:重要字段会拥有带有唯一值的classid属性的标签。我们可以利用这些有帮助的、描述性的命名约定,使用一种名为XPath的便捷语言。一旦你了解了它,这门语言本身是相当容易解释的;实际上,它的语法类似于任何路径(即目录路径、URL 路径等),所以如果你愿意,我可以直接让你访问以下 URL 来了解其细节:

www.w3schools.com/xpath/

无论如何,现在只需记住 XPath 是一种允许你通过路径返回节点集的简单语言。XPath 的特殊之处在于,在路径中,你可以通过包含各种过滤器来进一步细化搜索,例如,只返回特定classdiv。这就是具有描述性的classid属性发挥作用的地方,因为我们可以深入 HTML 中,高效地找到只对我们重要的节点。此外,如果你还需要额外的工具来解析结果 XML,你可以使用正则表达式(REGEX)来帮助你搜索。

最终,目标是要使解析尽可能健壮,因为你们最不想做的就是随着底层网站的微小、不重要更改而不断更新爬虫。同样,有时网站会进行重大更改,你确实需要更新爬虫,但目标是要尽可能健壮地编写它们。

在这一点上,我相信你们一定有很多问题。代码实际上长什么样?如何获取一个网站的 HTML?如何使用HtmlCleaner库?XPath 的一个例子是什么?之前,我的目标是引导你们理解什么是网络爬虫,在此过程中,我介绍了很多不同的技术和方法。现在,让我们通过一些代码实操,看看前面的每个步骤。以下是抓取我们 Blockbuster 视频游戏数据的步骤一和步骤二:

public class HTMLNavigator {
// STEP 1 - GET THE URL'S SOURCE CODE
public static CharSequence navigateAndGetContents(String url_str) throws IOException {
URL url = new URL(url_str);
// ESTABLISH CONNECTION TO URL
URLConnection conn = url.openConnection();
conn.setConnectTimeout(30000);
String encoding = conn.getContentEncoding();
if (encoding == null) {
encoding = "ISO-8859-1";
}
// WRAP BUFFERED READER AROUND INPUT STREAM
BufferedReader br = new BufferedReader (new InputStreamReader(conn.getInputStream(), encoding));
StringBuilder sb = new StringBuilder();
try {
String line;
while ((line = br.readLine()) != null) {
sb.append(line);
sb.append('\n');
}
} finally {
br.close();
}
return sb;
}
}

首先,我们有一个简单的便捷类,可以获取传入 URL 的源代码。它只是打开一个连接,设置一些标准的网页参数,然后读取输入流。我们使用StringBuilder高效地构建一个包含输入流每一行的大字符串,并最终关闭所有连接并返回该字符串。这个字符串将是传入 URL 的底层 HTML,也是下一步构建干净、有序的 XML 文档所需的。下面是相应的代码:

import org.htmlcleaner.CleanerProperties;
import org.htmlcleaner.HtmlCleaner;
import org.htmlcleaner.TagNode;
import org.htmlcleaner.XPatherException;
import app.helpers.HTMLNavigator;
import app.types.VideoGame;
public class VideoGameScraper {
private static String content;
private static final String BASE_URL = "http://www.blockbuster.com/
games/platforms/gamePlatform";
/**
* QUERY FOR GAMES OF CERTAIN PLATFORM
*
* @param type
* the platform type
* @return
* @throws IOException
* @throws XPatherException
*/
public static List<VideoGame> getVideoGamesByConsole(String type) throws IOException, XPatherException {
// CONSTRUCT FULL URL
String query = BASE_URL + type;
// STEPS 1 + 2 - GET AND CLEAN THE DYNAMIC URL
TagNode node = getAndCleanHTML(query);
// STEP 3 - PARSE AND ADD GAMES
List<VideoGame> games = new ArrayList<VideoGame>();
. . .
return games;
}
/**
* CLEAN AND STRUCTURE THE PASSED IN HTML
*
* @param result
* the underlying html
* @return
* @throws IOException
*/
private static TagNode getAndCleanHTML(String result) throws IOException {
String content = HTMLNavigator.navigateAndGetContents(result). toString();
VideoGameScraper.content = content;
// USE HTMLCLEANER TO STRUCTURE HTML
HtmlCleaner cleaner = new HtmlCleaner();
CleanerProperties props = cleaner.getProperties();
props.setOmitDoctypeDeclaration(true);
return cleaner.clean(content);
}
.
.
.
}

在这里,我们首先编写一个简单的方法,允许我们连接到结果 URL 并获取其底层的源代码。然后我们取那个结果并传递给一个清理方法,该方法实例化我们HtmlCleaner类的新实例并调用clean()方法。这个方法将结构化底层的 HTML 成为一个格式良好的 XML 文档,并返回 XML 的根作为一个TagNode对象。最后一步只是查看底层的源代码,确定正确的 XPath 是什么,然后在给定的根TagNode上运行这些 XPath。Blockbuster 视频游戏租赁页面的缩略源代码如下所示:

<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html lang="en" xml:lang="en" >
<head>
<body class="full">
<script type="text/javascript">
<div class="body clearDiv">
<div id="pageMask">&nbsp;</div>
<div id="boxPopup">&nbsp;</div>
<div id="head" class="head">
<style type="text/css">
<div>
<div id="gamesNav" class="secondaryNav">
<script type="text/javascript" language="javascript">
<div class="page clearDiv">
<div class="main contentsMain clearDiv">
<div class="primary clearDiv">
<span class="contentsDM"></span>
<span class="contentsLB"></span>
<img align="right" src="img/gameXboxOrig.gif" alt="Xbox Games">
<h1>Action &amp; Adventure Video Games</h1>
<div class="pagination">
<div class="gb6 listViewHeader">
<div class="">
<div id="4453" class="addToQueueEligible game sizeb gb6 bvr-gamelistitem ">
<mkt marketingitemid="4453" catalystinfo="A" listname="gameActionAdventure"></mkt>
<a onmouseover="if(DndUtil.windowLoaded){ new GameRollover(this); }" href="http:///games/catalog/gameDetails/4136" title="Superman Returns: The Video Game">
<img class="box" height="143" width="100" src="img/g25653wauzo. jpg?wid=100&hei=143">
</a>
<div class="details">
<h4>
<a onmouseover="if(DndUtil.windowLoaded){new GameRollover(this);}" href="http:///games/catalog/gameDetails/4136" title="Superman Returns: The Video Game">Superman Returns: The Video Game</a>
</h4>
<dl class="release">
<dl class="rated">
<div class="platform">
<dl class="movieInfo">
<div class="summary ">
<p class="readMore">
<div class="rolloverDetailsDiv" contentsrc="img/false">&nbsp;</div>
</div>
<div class="movieOptions">
<div id="movieRating" class="ratingWidget">
</div>
</div>
...

但是请注意,这段源代码是截至我撰写本文时的,不能保证保持不变。然而,从上面的源代码中,我们可以看到每个游戏都列在一个带有类addToQueueEligible game sizeb gb6 bvr-gamelistitemdiv标签中。这是一个相当长的类名,但我们可以确信,通过搜索带有这个类标签的divs,我们会找到视频游戏,而且只有视频游戏,因为类标签涉及到将符合条件的游戏添加到队列中。

现在,一旦我们找到了那些想要的divs,我们会发现我们需要的节点只是第一个a节点,以及该a节点的相应img标签。因此,为了分别获取标题和图片 URL,我们想要的 XPath 应该如下所示:

//div[@class='addToQueueEligible game sizeb gb6 brv-gamelistitem']/a[1]
//div[@class='addToQueueEligible game sizeb gb6 brv-gamelistitem']/a[1]/img

有了这个,现在让我们看一下我们抓取器的完整代码:

import org.htmlcleaner.CleanerProperties;
import org.htmlcleaner.HtmlCleaner;
import org.htmlcleaner.TagNode;
import org.htmlcleaner.XPatherException;
import app.helpers.HTMLNavigator;
import app.types.VideoGame;
public class VideoGameScraper {
private static String content;
// XPATH FOR GETTING TITLE NAMES
private static String TITLE_EXPR = "//div[@class='%s']/a[1]";
// XPATH FOR GETTING IMAGE URLS
private static String IMG_EXPR = "//div[@class='%s']/a[1]/img";
// BASE OF BLOCKBUSTER URL
public static final String BASE_URL = "http://www.blockbuster.com/ games/platforms/gamePlatform";
/**
* QUERY FOR GAMES OF CERTAIN PLATFORM
*
* @param type
* the platform type
* @return
* @throws IOException
* @throws XPatherException
*/
public static List<VideoGame> getVideoGamesByConsole(String type) throws IOException, XPatherException {
// CONSTRUCT FULL URL
String query = BASE_URL + type;
// USE HTMLCLEANER TO STRUCTURE HTML
TagNode node = getAndCleanHTML(query);
// ADD GAMES
List<VideoGame> games = new ArrayList<VideoGame>();
games.addAll(grabGamesWithTag(node, "addToQueueEligible game sizeb gb6 bvr-gamelistitem ", type));
return games;
}
/**
* GIVEN THE STRUCTURED HTML, PARSE OUT NODES OF THE PASSED IN TAG
*
* @param head
* the head of the structured html
* @param tag
* the tag we are looking for
* @param type
* the platform type
* @return
* @throws XPatherException
*/
private static List<VideoGame> grabGamesWithTag(TagNode head, String tag, String type) throws XPatherException {
// RUN VIDEO GAME TITLE AND IMAGE XPATHS
Object[] gameTitleNodes = head.evaluateXPath(String.format (TITLE_EXPR, tag));
Object[] imgUrlNodes = head.evaluateXPath(String.format (IMG_EXPR, tag));
// ITERATE THROUGH VIDEO GAMES
List<VideoGame> games = new ArrayList<VideoGame>();
for (int i = 0; i < gameTitleNodes.length; i++) {
TagNode gameTitleNode = (TagNode) gameTitleNodes[i];
TagNode imgUrlNode = (TagNode) imgUrlNodes[i];
// BY LOOKING AT THE HTML, WE CAN DETERMINE
// WHICH ATTRIBUTES OF THE NODE TO PULL
String title = gameTitleNode.getAttributeByName("title");
String imgUrl = imgUrlNode.getAttributeByName("src");
// BUILD OUR VIDEO GAME OBJECT AND ADD TO LIST
VideoGame v = new VideoGame(title, imgUrl, type);
games.add(v);
}
return games;
}
/**
* CLEAN AND STRUCTURE THE PASSED IN HTML
*
* @param result
* the underlying html
* @return
* @throws IOException
*/
private static TagNode getAndCleanHTML(String result) throws IOException {
. . .
}
}

就这样!我们之前已经看过了大部分代码,所以实际上我们只需要关注grabGamesWithTag()方法。该方法的第一部分是将我们之前看到的 HTML 模式(网站的源代码)与我们的 XPath 格式结合起来。此时,我们有一个有效的 XPath,它将引导我们找到视频游戏的标题以及视频游戏的图片 URL。我们需要使用HtmlCleaner中的方法来实际运行这个 XPath 命令,如下所示:

Object[] gameTitleNodes = head.evaluateXPath(String.format (TITLE_EXPR, tag));

这将返回一个Objects列表,然后可以将其转换为单独的TagNode对象。然后我们需要做的是遍历数组中的每个Object,将其转换为TagNode,并提取节点的值或属性以获取所需的数据。我们可以在方法以下部分看到这一点:

// ITERATE THROUGH VIDEO GAMES
List<VideoGame> games = new ArrayList<VideoGame>();
for (int i = 0; i < gameTitleNodes.length; i++) {
TagNode gameTitleNode = (TagNode) gameTitleNodes[i];
TagNode imgUrlNode = (TagNode) imgUrlNodes[i];
// BY LOOKING AT THE HTML, WE CAN DETERMINE
// WHICH ATTRIBUTES OF THE NODE TO PULL
String title = gameTitleNode.getAttributeByName("title");
String imgUrl = imgUrlNode.getAttributeByName("src");
// BUILD OUR VIDEO GAME OBJECT AND ADD TO LIST
VideoGame v = new VideoGame(title, imgUrl, type);
games.add(v);
}

在这两种情况下,我们需要的是节点的特定属性值,而不是节点的值。如果是一个值的话,我们的代码看起来会更像下面这样:

List<VideoGame> games = new ArrayList<VideoGame>();
for (int i = 0; i < gameTitleNodes.length; i++) {
TagNode gameTitleNode = (TagNode) gameTitleNodes[i];
TagNode imgUrlNode = (TagNode) imgUrlNodes[i];
String title = gameTitleNode.getText().toString();
String imgUrl = imgUrlNode.getAttributeByName("src");
// BUILD OUR VIDEO GAME OBJECT AND ADD TO LIST
VideoGame v = new VideoGame(title, imgUrl, type);
games.add(v);
}

在这一点上,我们已经快速了解了网络爬取的基本知识。再次强调,网络爬取是一种技术和艺术,需要时间去适应和掌握,但它是一项非常棒的技术,可以为你打开无数的网络数据挖掘机会。现在,关注本章介绍的概念,而不是实际的代码。之所以这样说,是因为你的代码看起来会很大程度上取决于你试图爬取的网页。不会改变的是爬取背后的概念,因此使用本章提到的三个步骤作为指导,你可以为任何网页编写爬虫。

扩展 HTTP servlet 以支持 GET/POST 方法

现在我们已经编写好了网络爬虫,我们需要一种方法来处理返回的VideoGame对象,并将它们实际存储到我们的数据库中。此外,我们还需要一种方式与服务器通信,一旦服务器启动并运行,告诉它去抓取网站内容并插入到我们的 JDO 数据库中。我们与服务器通信的网关是通过所谓的 HTTP servlet,这在本书前面简要提到过。

以这种方式设置后端将在我们稍后讨论 CRON 作业时特别有用,这些作业为了自动运行某种功能,需要一个 servlet 与之通信(关于这一点我们很快会详细介绍)。现在,让我们看看如何扩展HttpServlet类并实现其doGet()方法,该方法将监听并处理所有发送给它的 HTTP GET 请求。但首先,HTTP GET 请求到底是什么?实际上,HTTP 网络请求只是用户向某个服务器发起的请求,并通过网络(即互联网)发送。根据请求类型,服务器将处理并回送 HTTP 响应给用户。然后,有两种常见的 HTTP 请求类型:

  • GET 请求——仅用于检索数据的网络请求。这些请求通常会要求服务器查询并返回某种数据。

  • POST 请求——提交数据处理的网络请求。通常,这会要求服务器插入用户提交的某种数据。

在这种情况下,由于我们既不需要获取用户数据,也不需要提交任何用户数据(实际上我们根本不与任何用户交互),使用哪种类型的请求实际上并没有区别,因此我们将使用更简单的 GET 请求,如下所示:

import javax.servlet.ServletException;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
// EXTEND THE HTTPSERVLET CLASS TO MAKE THIS METHOD AVAILABLE
// TO EXTERNAL WEB REQUESTS, NAMELY CLIENTS AND CRON JOBS
public class VideoGameScrapeServlet extends HttpServlet {
private ArrayList<VideoGame> games;
/**
* METHOD THAT IS HIT WHEN HTTP GET REQUEST IS MADE
*
* @param request
* a servlet request object (any params passed can be retrieved
* with this)
* @param response
* a servlet response that you can embed data back to user
* @throws IOException
* @throws ServletException
*/
public void doGet(HttpServletRequest request, HttpServletResponse response) throws IOException, ServletException {
games = new ArrayList<VideoGame>();
String message = "Success";
try {
// GRAB GAMES FROM ALL PLATFORMS
games.addAll(
VideoGameScraper.getVideoGamesByConsole(VideoGameConsole.DS));
games.addAll(
VideoGameScraper.getVideoGamesByConsole(VideoGameConsole.PS2));
games.addAll(
VideoGameScraper.getVideoGamesByConsole(VideoGameConsole.PS3));
games.addAll(
VideoGameScraper.getVideoGamesByConsole(VideoGameConsole.PSP));
games.addAll(
VideoGameScraper.getVideoGamesByConsole(VideoGameConsole.WII));
games.addAll(
VideoGameScraper.getVideoGamesByConsole(VideoGameConsole.XBOX));
} catch (Exception e) {
e.printStackTrace();
message = "Failed";
}
// HERE WE ADD ALL GAMES TO OUR VIDEOGAME JDO WRAPPER
VideoGameJDOWrapper.batchInsertGames(games);
// WRITE A RESPONSE BACK TO ORIGINAL HTTP REQUESTER
response.setContentType("text/html");
response.setHeader("Cache-Control", "no-cache");
response.getWriter().write(message);
}
}

因此,这个方法本身非常简单。我们之前已经有了getVideoGamesByConsole()方法,它会执行所有抓取操作,并返回一个VideoGame对象列表作为结果。然后,我们只需针对想要的所有游戏机运行它,最后使用我们巧妙的 JDO 数据库包装类,并调用其batchInsertGames()方法以快速插入数据。完成这些后,我们获取传入的 HTTP 响应对象,并快速向用户编写一些信息,以告知他们抓取是否成功。在这种情况下,我们没有使用传入的HttpServletRequest对象,但如果请求者在 URL 中传递参数,这个对象将非常有用。例如,假设你想以只抓取一个特定的游戏平台而非所有平台的方式来编写 Servlet。在这种情况下,你需要某种方式将平台类型参数传递给 Servlet,并在 Servlet 中提取传入的参数值。正如我们之前看到的雅虎财经允许你使用键值对s传递股票代码一样,为了传递平台类型,我们可以简单地执行以下操作:

http://{your-GAE-base-url}.appspot.com/videoGameScrapeServlet?type =Xbox

然后,在 Servlet 端执行以下操作:

public void doGet(HttpServletRequest request, HttpServletResponse response) throws IOException, ServletException {
String type = request.getParameter("type");
games = new ArrayList<VideoGame>();
String message = "Success";
try {
// GRAB GAMES FROM SPECIFIC PLATFORM
games.addAll(VideoGameScraper.getVideoGamesByConsole(type));
} catch (Exception e) {
e.printStackTrace();
message = "Failed";
}
// ADD GAMES TO JDO DATABASE
VideoGameJDOWrapper.batchInsertGames(games);
// WRITE A RESPONSE BACK TO ORIGINAL HTTP REQUESTER
response.setContentType("text/html");
response.setHeader("Cache-Control", "no-cache");
response.getWriter().write(message);
}

很简单,对吧?你只需要确保 URL 中使用的键与 Servlet 类中请求的参数相匹配。现在,为了将所有这些连接在一起,最后一步是在你的 GAE 项目中定义 URL 路径——即确保你的 GAE 项目知道 URL 模式实际上指向你刚刚编写的这个类。这可以在你的 GAE 项目的/war/WEB-INF/目录中找到,具体是在web.xml文件中。你需要在其中添加以下内容,以确保 Servlet 名称和类路径与给定的 URL 模式相匹配:

<?xml version="1.0" encoding="utf-8"?>
<web-app  version="2.5">
<servlet>
<servlet-name>videoGameScrapeServlet</servlet-name>
<servlet-class>app.httpservlets.VideoGameScrapeServlet</servlet-class>
</servlet>
<servlet-mapping>
<servlet-name>videoGameScrapeServlet</servlet-name>
<url-pattern>/videoGameScrapeServlet</url-pattern>
</servlet-mapping>
</web-app>

在这一点上,我们有了抓取器,我们有了 JDO 数据库,甚至我们的第一个 Servlet 也已经连接并准备就绪。最后一部分是定期安排你的抓取器运行;这样,你的数据库就有最新的、最及时的数据,而不需要你每天坐在电脑前手动调用你的抓取器。在下一节中,我们将看到如何使用 CRON 作业来实现这一点。

安排 CRON 作业

首先,让我们定义一下 CRON 作业是什么。cron这个术语最初指的是 Unix 中基于时间的工作调度程序,允许你安排作业/脚本在特定时间定期运行。同样的概念可以应用于网页请求,而在我们的情况下,目标是定期运行我们的网页抓取器并更新数据库中的数据,而无需我们的干预。GAE 平台之所以方便使用,另一个原因是它让安排 CRON 作业变得非常简单。为此,我们只需在 GAE 项目的/war/WEB-INF/目录中创建一个cron.xml文件。在这个 XML 文件中,我们添加以下代码:

<?xml version="1.0" encoding="UTF-8"?>
<cronentries>
<cron>
<url>/videoGameScrapeServlet</url>
<description>Scrape video games from Blockbuster</description>
<schedule>every day 00:50</schedule>
<timezone>America/Los_Angeles</timezone>
</cron>
</cronentries>

这相当于是自解释的。首先,我们定义了一个名为<cronentries>的根标签,在这些标签内,我们可以插入任意数量的<cron>标签——每个标签都表示一个计划中的进程。在这些<cron>标签中,我们需要告诉调度程序我们想要访问的 URL 是什么(这当然会相对于根 URL),以及计划本身(在我们的案例中,是每天凌晨 12:50)。其他可选标签有描述标签、时区标签和/或目标标签,允许你指定调用指定 URL 的 GAE 项目的哪个版本。

在我的案例中,我让调度程序每天太平洋标准时间凌晨 12:50 运行该任务,但其他调度格式的例子如下:

every 12 hours
every 5 minutes from 10:00 to 14:00
2nd,third mon,wed,thu of march 17:00
every monday 09:00
1st monday of sep,oct,nov 17:00
every day 00:00

我不会深入讲解调度标签的确切语法,但你可以看出它非常直观。然而,对于那些想要在 GAE 中了解有关 CRON 作业更多信息或者查看一些较少使用的功能的人,可以随时查看以下 URL 以全面了解 CRON 作业:

code.google.com/appengine/docs/java/config/cron.html

但就我们的示例而言,我们之前所做的工作已经足够,因此我们就此打住!

总结

在本章中,我们再次涉猎了很多内容。我们从探讨收集数据的各种方法开始本章。在有些情况下,其他公司发布的便捷 API 可供我们使用和查询(尽管在存储这些数据时必须注意法律问题)。然而,很多时候我们需要自己出去抓取数据,这可以通过网页抓取完成。

在下一节中,我们通过一个网页抓取入门教程进行了学习——从网页抓取是什么以及执行抓取需要采取哪些步骤的高层次概念开始,到具体实现结束。我们通过抓取 Blockbuster 网站获取可供租借的最新视频游戏为例进行了学习,在这个过程中,我们编写了我们的第一个 XPath 表达式并实现了第一个 HTTP servlet。

在实现我们的 HTTP servlet 时,我们简要讨论了两种常见的 HTTP 请求类型(GET 和 POST 请求),然后实现了一个 HTTP GET 请求,这将允许我们调用我们的视频游戏抓取器类,收集聚合的VideoGame对象,并使用前一章中的便捷包装类将它们插入到我们的 JDO 数据库中。

最后,我们通过探讨如何安排对 Blockbuster 网站的抓取来结束本章,以确保获取最新和最及时的数据,而无需我们每天手动调用抓取器。我们介绍了一种称为 CRON 作业的特殊技术,并使用 GAE 平台实现了一个。

在下一章也是最后一章中,我们将尝试把所学的一切融合在一起。更具体地说,既然我们系统的数据收集和插入部分已经运行起来了,我们将实现几个额外的 servlet,使我们能够发起 HTTP GET 请求并检索各种类型的数据。接下来,我们将研究代码的客户端部分,看看如何从 Android 应用程序发起这些 GET 请求并解析响应以获取需要的数据。

第十章:将一切整合到一起

最后,是时候将所有内容整合到一起了。在之前的第八章,探索外部数据库中,我们通过创建一个新的 Google App Engine (GAE) 项目并构建 JDO 数据库,开始了编写一个 Blockbuster 游戏应用示例。我们首先定义了VideoGame表应该是什么样子,然后编写了一些方便的包装方法,允许我们从后端检索、插入、更新和/或删除VideoGame数据。然后在第九章,收集和存储数据中,我们探讨了我们可以通过使用方便的 API 或者编写抓取器来完成数据的收集。在我们的示例中,需要一个抓取器,因此我们编写了一些代码来首先清理和构建 Blockbuster 的游戏租赁页面,然后最终导航和解析所需数据。最后一步就是重新介绍 HTTP servlet,并查看我们如何实现一个 servlet,当被访问时,它会抓取并更新我们数据库中的最新游戏。

现在,我们将通过编写一个 HTTP servlet 来完成应用程序的编写,该 servlet 实际上会查询并返回数据(与之前的示例不同,之前的示例只是返回成功或失败的消息),一旦返回数据,我们将编写一些简单的 XML 解析器和列表适配器,以展示一旦数据在移动端上该如何处理。然后,你将拥有一个可以定期抓取和更新自身数据的功能齐全的后端,一系列允许你根据平台独立检索数据的 HTTP servlet,以及一个 Android 应用程序,它将解析数据并将其绑定到用户可以看到的 UI 上。

实现 HTTP GET 请求

在上一章中,我们简要介绍了 GET 和 POST 请求之间的区别。在我们的应用程序开发中的下一步是在 GAE 服务器端编写几个类,允许我们访问一个 URL 并获取视频游戏对象的列表。

这意味着我们需要重写另一个 HTTP servlet,它可能会带有一个参数,指示我们正在寻找哪个游戏平台。直观地,一旦我们知道我们正在寻找的平台,我们会回忆起之前我们的 JDO 数据库包装方法之一涉及到查询特定平台的所有游戏。因此,我们很可能会再次利用我们的 JDO 包装类。

然而,你可能也会回忆起我们的 JDO 数据库返回的行不是字符串,而是对象,因此我们需要将每个VideoGame对象转换为某种可读的、格式化的字符串,无论是 XML 还是 JSON。有了这些初步的想法和直觉,让我们看看你将如何实现这个新的 GET 请求:

public class GetVideoGames extends HttpServlet {
// HTTP GET REQUEST SINCE WE'RE REQUESTING FOR DATA
public void doGet(HttpServletRequest request, HttpServletResponse response) throws IOException, ServletException {
String platform = request.getParameter("type");
// USE OUR JDO WRAPPER TO QUERY FOR GAMES BY PLATFORM
List<VideoGame> games = VideoGameJDOWrapper.getGamesByType(platform);
// WRAP GAMES INTO XML FORMAT
String ret = GamesToXMLConverter.convertGamesToXML(games);
// SET THE RESPONSE TYPE TO XML
response.setContentType("text/xml");
response.setHeader("Cache-Control", "no-cache");
// WRITE DATA TO RESPONSE
response.getWriter().write(ret);
}
}

一切都应该看起来很熟悉,逻辑相当简单。唯一不清楚的部分是在最后我传入一个VideoGame对象列表并返回一个字符串。顾名思义,我编写了一个简单的类,它接收VideoGame对象,提取它们的字段,并将它们组织成格式良好的 XML 代码(同样,你也可以使用 JSON)。让我们快速看看我是如何定义我的GamesToXMLConverter类的:

public class GamesToXMLConverter {
public static String convertGamesToXML(List<VideoGame> games) {
String content = "";
for (VideoGame g : games) {
// WRAP EACH GAME IN ITS OWN TAG
content += convertGameToXml(g);
}
// WRAP ALL GAME TAGS TOGETHER INTO ROOT TAG
String ret = addTag("games", content);
return ret;
}
/**
* METHOD FOR CONVERTING OBJECT TO XML FORMAT
*
* @param g
* a video game object
* @return
*/
public static String convertGameToXml(VideoGame g) {
String content = "";
// ADD TAG FOR NAME
content += addTag("name", g.getName().replaceAll("&", "and"));
// ADD TAG FOR ID
content += addTag("id", String.valueOf(g.getId()));
// ADD TAG FOR IMAGE IF NOT NULL
if (g.getImgUrl() != null) {
content += addTag("imgUrl", g.getImgUrl().getValue());
}
// ADD TAG FOR TYPE
content += addTag("type", VideoGameConsole.convertIntToString(g.getConsoleType()));
// WRAP ENTIRE GAME IN <game> TAGS
String ret = addTag("game", content);
return ret;
}
public static String addTag(String tag, String value) {
return ("<" + tag + ">" + value + "</" + tag + ">");
}
}

噔噔噔——没什么复杂的。实际上,你可以按照自己喜欢的方式编写 XML/JSON 转换器,如果你足够努力寻找,我敢打赌肯定有方便的库是为了帮你完成这项工作而设计的。然而,正如本书的主题,更多地关注概念而不是我的实际代码——这个想法是你深入到 JDO 数据库中,获取一个对象列表,然后你需要考虑一种简洁的方式来将那些对象写入返回的HttpServletResponse对象中。

同样,就像我们之前的 HTTP servlet 一样,为了让我们的 GAE 项目识别这是一个有效的 servlet,我们需要在/war/WEB-INF/web.xml文件中将其定义为一个 servlet。

<?xml version="1.0" encoding="utf-8"?>
<servlet>
<servlet-name>getVideoGames</servlet-name>
<servlet-class>app.requests.GetVideoGames</servlet-class>
</servlet>
<servlet-mapping>
<servlet-name>getVideoGames</servlet-name>
<url-pattern>/getVideoGames</url-pattern>
</servlet-mapping>

一旦我们定义了名称和 URL 模式,我们只需部署项目并访问以下 URL:

http://{你的项目名}.appspot.com/getVideoGames?type={类型}

完成了。对于那些跟随进度的读者,我邀请你们检查一下,看看是否能得到一个格式良好的数据列表。否则,欢迎查看以下链接来查看我的结果:

http://entertainmentapp.appspot.com/getVideoGames?type=Xbox

http://entertainmentapp.appspot.com/getVideoGames?type=Ps3

以下是给那些在移动中阅读此内容的读者的截图:

实现 HTTP GET 请求

现在,让我们回到 Android 端,看看我们是如何发起请求并处理/解析结果。

回到 Android:解析响应

既然我们的后端已经完全完成,剩下的就是从 Android 实现这些 HTTP 请求,解析数据,并在获取数据后将其绑定到 UI 上(尽管这可能需要重新复习第六章,绑定到 UI)。

首先,你需要构建一个 HTTP 客户端,以便你可以发起 GET/POST 请求。这个 HTTP 客户端本质上是一个载体,你可以通过它发起各种 HTTP 请求。HTTP 客户端要求你设置一些 HTTP 参数,以确定请求应该如何进行。然后,根据这些参数,客户端知道如何相应地处理每个请求。例如,这样的一个参数就是告诉 HTTP 客户端如何处理 HTTP 与 HTTPS 请求(即通过非安全通道与安全通道发起的请求)。每个通道都要求你指定不同的端口,因此你需要在客户端中相应地定义这些端口。在下面的代码中,你可以看到一个为 HTTP 和 HTTPS 请求配置的 HTTP 客户端:

public class ConnectionManager {
public static DefaultHttpClient getClient() {
DefaultHttpClient ret = null;
// SET PARAMETERS
HttpParams params = new BasicHttpParams();
HttpProtocolParams.setVersion(params, HttpVersion.HTTP_1_1);
HttpProtocolParams.setContentCharset(params, "utf-8");
params.setBooleanParameter("http.protocol.expect-continue", false);
// REGISTER SCHEMES FOR HTTP AND HTTPS REQUESTS
SchemeRegistry registry = new SchemeRegistry();
registry.register(new Scheme("http", PlainSocketFactory.getSocketFactory(), 80));
final SSLSocketFactory sslSocketFactory = SSLSocketFactory.getSocketFactory();
sslSocketFactory.setHostnameVerifier (SSLSocketFactory.BROWSER_COMPATIBLE_HOSTNAME_VERIFIER);
registry.register(new Scheme("https", sslSocketFactory, 443));
ThreadSafeClientConnManager manager = new ThreadSafeClientConnManager(params, registry);
ret = new DefaultHttpClient(manager, params);
return ret;
}
}

一旦有了这些,我更喜欢构建一些简单的 GET/POST 包装方法,当你传递一个 HTTP 客户端和 URL 时,它会将结果作为字符串返回:

public class GetMethods {
/**
* MAKE AN HTTP GET REQUEST
*
* @param mUrl
* the url of the request you're making
* @param httpClient
* a configured http client
* @return
*/
public static String doGetWithResponse(String mUrl, DefaultHttpClient httpClient) {
String ret = null;
HttpResponse response = null;
// INITIATE THE GET METHOD WITH THE DESIRED URL
HttpGet getMethod = new HttpGet(mUrl);
try {
// USE YOUR HTTP CLIENT TO EXECUTE THE METHOD
response = httpClient.execute(getMethod);
System.out.println("STATUS CODE: " + String.valueOf(response.getStatusLine(). getStatusCode()));
if (null != response) {
// CONVERT HTTP RESPONSE TO STRING
ret = getResponseBody(response);
}
} catch (Exception e) {
System.out.println(e.getMessage());
}
return ret;
}
public static String getResponseBody(HttpResponse response) {
String response_text = null;
HttpEntity entity = null;
try {
// GET THE MESSAGE BODY OF THE RESPONSE
entity = response.getEntity();
if (entity == null) { throw new IllegalArgumentException("HTTP entity may not be null"); }
// IF NOT NULL GET CONTENT AS STREAM
InputStream instream = entity.getContent();
if (instream == null) { return ""; }
// CHECK FOR LENGTH
if (entity.getContentLength() > Integer.MAX_VALUE) { throw new IllegalArgumentException(
"HTTP entity too large to be buffered in memory"); }
// GET THE CHARACTER SET OF THE RESPONSE
String charset = null;
if (entity.getContentType() != null) {
HeaderElement values[] = entity.getContentType(). getElements();
if (values.length > 0) {
NameValuePair param = values[0]. getParameterByName("charset");
if (param != null) {
charset = param.getValue();
}
}
}
if (charset == null) {
charset = HTTP.DEFAULT_CONTENT_CHARSET;
}
// ONCE CHARSET IS OBTAINED - READ FROM STREAM
Reader reader = new InputStreamReader(instream, charset);
StringBuilder buffer = new StringBuilder();
try {
// USE A BUFFER TO READ FROM STREAM
char[] tmp = new char[2048];
int l;
while ((l = reader.read(tmp)) != -1) {
buffer.append(tmp, 0, l);
}
} finally {
reader.close();
}
// CONVERT BUFFER TO STRING
response_text = buffer.toString();
} catch (Exception e) {
e.printStackTrace();
}
return response_text;
}
}

一开始,这可能看起来非常令人畏惧,尤其是对于那些从未见过这些技术或类的人。是的——涉及很多新的类,但这并不是火箭科学;实际上,类名都非常直观和具有描述性,除了这些并没有太多复杂的内容。

第一种方法非常简单。Java 中有一个HttpGet类,它包含在 Android SDK 和 Java SDK 中,我们可以用 URL 来实例化它。接下来,我们将这个HttpGet对象传递给我们的 HTTP 客户端,并等待响应返回。响应最终会以一个HttpResponse对象的形式返回,在这个对象内部有一些描述性字段,可以告诉我们 HTTP 状态码、响应内容(这是我们很快需要的东西)等。状态码是一个很有用的东西,因为它会告诉我们 GET 请求是否成功,如果不成功,它还会显示失败的错误。有了这些不同的错误代码,我们可以相应地处理每个事件,例如,如果服务器宕机,那么我们运气不佳,应该告诉用户稍后再检查,或者可能引导他们访问应用程序的离线版本。另一方面,如果只是临时的连接问题,那么我们可能会默默重试请求。

一旦我们得到响应并确认请求成功,接下来就是获取响应体了!下一节将介绍这部分代码——即getResponseBody()方法。这个方法稍微有些复杂,但希望内联注释能帮助你理解正在发生的事情。从高层次来看,我们本质上是在获取HttpResponse对象的内容体,在这个例子中称为实体。然而,实体是一个单独的对象,包含许多描述性字段,但我们实际上感兴趣的是HttpEntity对象的字符串表示。因此,我们从HttpEntity请求一个InputStream,这将允许我们使用一个StringBuilder对象逐行流式传输内容体的字符。现在,中间的其余代码只是一系列检查,以确保实际上有消息需要缓冲,并且,如果有,它的大小不会太大,以至于我们的缓冲区无法处理(即它不会超过字符串的最大大小)。最后,我们只需要检索内容体的字符集,这样我们的InputStreamReader在将消息转换为字符时就知道使用哪个字符集了。

现在,以下是我们要如何使用前两个类从 Android 客户端实际发起 GET 请求的方法:

public class GetVideoGamesAndroid {
private static String URL_BASE = "http://entertainmentapp.appspot.com";
private static String REQUEST_BASE = "/getVideoGames?type=";
// THIS RETRIEVES THE HTTP CLIENT CONFIGURED ABOVE
private static DefaultHttpClient httpClient = ConnectionManager.getClient();
// PASS IN THE PLATFORM YOU WANT I.E. XBOX, PS3, ETC
public static List<VideoGame> getGamesByType(String type) {
// CONSTRUCT GET REQUEST URL
String url = URL_BASE + REQUEST_BASE + type;
// XML RESPONSE AS A STRING GETS RETURNED
String response = GetMethods.doGetWithResponse(url, httpClient);
// RUN THROUGH SIMPLE XML PARSER
List<VideoGame> games = ObjectParsers.parseGameResponse(response);
return games;
}
}

在这一点上,你会注意到实际发生的事情确实在我们的GetMethods类中,一旦实现了这个类,发起 GET 请求就变得相当简单:只需要一个 URL。那么在这种情况下,XML 解析器是什么样的呢?嗯,你可以根据 XML 的复杂程度以及你对各种 XML 文档解析器的熟悉程度以多种方式实现它。对于极其简单的 XML(即只有单层节点的文档),有时使用简单的正则表达式命令就可以解决问题。在更复杂的 XML 中,有时使用 Java 内置的SAXParser类甚至使用我们的朋友HtmlCleaner也会有所帮助。请注意,在很多情况下返回的数据也可能是 JSON 格式,在这种情况下,你需要编写一些简单的 JSON 解析器,这些解析器获取各种键值对,并在移动端重新构建VideoGame对象。

由于所有这些先前的依赖,我将实际的parseGameResponse()方法的实现留给你们去完成——目标很明确,如果你需要回顾数据的样子,只需参考本章的第一张图片。现在你需要做的是解析它,这应该是一个相对简单的练习。我要提到的最后一点是,通常这些 HTTP 请求可能需要一些时间(至少几秒钟,有时根据服务器上执行的工作量可能会达到 10-20 秒)。由于 Android 操作系统如果主 UI 线程长时间被占用(根据情况可能 5-10 秒),会抛出“应用无响应”(ANR)错误,我强烈建议在单独的线程上执行所有 HTTP 请求。你可以使用传统的RunnableHandler类来实现这一点,但 Android 也为你提供了很好的封装类,如AsyncTask类。我还鼓励你阅读谷歌朋友的这篇文章,了解更多关于设计响应式应用的信息:

developer.android.com/guide/practices/design/responsiveness.html

所以现在,我们已经发出了 GET 请求,解析了数据,并且在移动端拥有了一个漂亮的VideoGame对象列表,这些对象是我们从服务器接收到的VideoGame对象的副本。剩下要做的就是使用我们在书中前面看到的ListAdapter之一并将其绑定到 UI!

最后的步骤:绑定到 UI(再次)

现在是最后一步——将数据绑定到用户界面。对于那些已经阅读完全书的读者来说,这一部分应该非常熟悉,因此我会尽量简洁但全面的讲解。

在前面的章节中,我们实际上已经将所有网络请求连接在一起,无论是在应用端还是在服务器端,因此现在我们应该能够从任何移动应用无缝地发出 GET 请求。我们还研究了如何解析返回的响应(这同样作为一个练习留给你,因为响应可能以任何方式返回)并将数据从字符串形式转换回VideoGame对象形式。

现在让我们回顾一下第六章,绑定到 UI。在那章中,我们了解了ListAdapters的两个子类:BaseAdapterCursorAdapter。您还记得,当我们的数据存储在 SQLite 数据库中时,使用CursorAdapter。后续对 SQLite 数据库的查询以Cursor对象的形式返回,然后由CursorAdapter类包装。在我们的VideoGame示例中,目前有一个对象列表,而不是Cursor。这并不是说我们不能将结果存储到 SQLite 数据库中,在应用程序端有效地创建一个缓存(还记得这些吗?),然后向缓存发出查询以获取Cursor。但为了简单起见,让我们坚持使用我们的VideoGame对象列表,并简单地使用专为这类列表设计的BaseAdapter。它的代码可能如下所示:

public class VideoGameBaseAdpater extends BaseAdapter {
// REMEMBER CONTEXT SO THAT CAN BE USED TO INFLATE VIEWS
private LayoutInflater mInflater;
// LIST OF VIDEO GAMES
private List<VideoGame> mItems = new ArrayList<VideoGame>();
public VideoGameBaseAdpater(Context context, List<VideoGame> items) {
// HERE WE CACHE THE INFLATOR FOR EFFICIENCY
mInflater = LayoutInflater.from(context);
mItems = items;
}
public int getCount() {
return mItems.size();
}
public Object getItem(int position) {
return mItems.get(position);
}
public long getItemId(int position) {
return position;
UIUIdata, binding to}
public View getView(int position, View convertView, ViewGroup parent) {
VideoGameViewHolder holder;
// IF NULL THEN NEED TO INSTANTIATE IT BY INFLATING IT
if (convertView == null) {
convertView = mInflater.inflate(R.layout.list_entry, null);
holder = new VideoGameViewHolder();
holder.name_entry = (TextView) convertView.findViewById (R.id.name_entry);
holder.type_entry = (TextView) convertView.findViewById (R.id.number_type_entry);
convertView.setTag(holder);
} else {
// GET VIEW HOLDER BACK FOR FAST ACCESS TO FIELDS
holder = (VideoGameViewHolder) convertView.getTag();
}
// EFFICIENTLY BIND DATA WITH HOLDER
VideoGame v = mItems.get(position);
holder.name_entry.setText(v.getName());
String type = VideoGameConsole.convertIntToString (v.getConsoleType());
holder.type_entry.setText(type);
return convertView;
}
static class VideoGameViewHolder {
TextView name_entry;
TextView type_entry;
}
}

正如在第六章中,绑定到 UI,我们实现了一个自定义的BaseAdapter,创建了一个Contact对象的列表——在这种情况下,我们正在做一些非常相似的事情,但针对的是我们的VideoGame对象!请注意,我的VideoGameViewHolder只显示了游戏名称和游戏类型,并没有处理图片 URL。同样,通过使用ImageView,可以很容易地将这些内容整合到每一行中,但这需要将 URL 转换为 Bitmap 对象——虽然这并不难,但在我们的情况下是不必要的;现在您应该明白这个想法了。

完成这些后,我们只需要创建一个 Activity,它发起 GET 请求,获取结果中的VideoGames列表,并通过使用自定义的VideoGameBaseAdapter将其设置为ListAdapter。这个代码非常简单:

public class VideoGameBaseAdapterActivity extends ListActivity {
private List<VideoGame> games;
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.list);
// MAKE GET REQUEST TO RETRIEVE GAMES
games = GetVideoGamesAndroid.getGamesByType (VideoGameConsole.XBOX);
// USE VIDEO GAME ADAPTER
VideoGameBaseAdpater vAdapter = new VideoGameBaseAdpater(this, games);
// SET THIS ADAPTER AS YOUR LIST ACTIVITY'S ADAPTER
this.setListAdapter(vAdapter);
}
@Override
protected void onListItemClick(ListView l, View v, int position, long id) {
super.onListItemClick(l, v, position, id);
VideoGame vg = games.get(position);
String name = vg.getName();
System.out.println("CLICKED ON " + name);
}
}

完成后,我们的最终结果如下所示:

最后几步:再次绑定到 UI

完成了!给自己点个赞,因为我们刚刚完成了一个完整的数据中心应用程序!现在,我们不仅拥有一个功能齐全的后端,配备了自己的 HTTP 请求集合,而且还构建了一个有前景的 Android 应用程序,它能向后端发起 HTTP 请求,获取结果,并以简单的列表形式展示。

概述

我们已经到达了结尾,但在我们结束之前,让我们从开始到结束看看我们所学习和涵盖的所有令人惊叹的事情。我们通过查看 Android 上的各种本地存储方法开始了这本书——这些方法非常轻量级和高效,还有像 SQLite 数据库这样更复杂但同时也更强大的方法。

我们接着深入探讨了 SQLite 数据库——这可能是你在 Android 应用开发职业生涯中遇到的最常见的本地数据存储形式,然后进入了第三章,SQLite 查询。接下来,我们学习了如何通过将 SQLite 数据库包装在内容提供者中来向外部应用公开这些数据库。然后我们研究了 Android 操作系统中最受欢迎的内容提供者——联系人内容提供者,并实现了一些可能会遇到的一些常见查询。

在完全掌握了本地存储方法之后,我们继续学习如何通过各种ListAdapter类将这些本地数据源绑定到用户界面上。在这一章节中,我们看到了CursorAdapterBaseAdapter的实现和使用场景。

从那里,我们转向了以数据为中心的应用程序设计和编程的更全面视角。我们讨论了实际使用各种本地数据存储形式的方法,并引入了缓存概念,这是 SQLite 数据库的一个极其实用的用例。这自然引导我们考虑外部数据库,因为缓存通常与网络请求和网络编程密切相关。

我们以外部数据库结束了本书。我们讨论了可以使用的外部数据库的不同类型,并决定在示例实现中使用 Google App Engine (GAE)。使用 GAE,我们实现了一个完全功能的 JDO 数据库(全部在云端完成),此时我们还实现了一系列 HTTP Servlet,使我们能够进行 HTTP GET 和 POST 请求。最后,我们通过实现应用程序移动端的代码结束了本书——这让我们回到了 Android,并圆满完成了整个学习过程。

我希望通过这些内容,我们能更好地理解数据库(无论是本地的还是外部的)是如何融入开发强大、以数据为中心的 Android 应用程序的整体架构中的。祝你好运,开发愉快。

posted @ 2024-05-23 11:07  绝不原创的飞龙  阅读(23)  评论(0编辑  收藏  举报