安卓数据库编程(二)
原文:
zh.annas-archive.org/md5/178BF5D3B8A98AFC3DB2CE2ED8D821E4译者:飞龙
第五章:查询联系人表
在本书前面,我们探讨了如何通过重写SQLiteOpenHelper类为我们的应用程序构建一个 SQLite 数据库。然后,我们通过引入ContentProvider类扩展了对 Android 上数据库的理解,它允许我们将 SQLite 数据库暴露给外部应用程序,以及更一般地暴露给 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");
}
请注意,我们省略了到Phone和Data类的完整包路径,以再次让你了解架构的层次性质。在这里,由于我们现在针对的是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_NAME或ACCOUNT_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),并特别关注各种允许我们以列表形式绑定数据的类(这是显示数据行最常见和直观的方式)。
SimpleCursorAdapters和ListViews
在 Android 上有两种主要的数据检索方式,每种方式都有自己的ListAdapters类,这些类将知道如何处理和绑定传入的数据。我们熟悉的第一个数据检索方式是通过查询并获得Cursor对象。围绕Cursor的ListAdapters的子类被称为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_entry的TextView上,依此类推。定义好这些映射关系后,下一步就是实例化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的结果可以在以下屏幕截图中看到:
所有内容看起来都很好,除了电话号码下面漂浮的这些随机整数。为什么在每一行类型应该出现的地方有一堆 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);
那么最后这一切看起来如何呢?让我们快速看一下:
在这里,我们看到在每一行中,我们不是简单地显示电话号码的整数类型,而是可以按需看到实际的易读字符串类型!现在看起来好多了。
基础适配器(BaseAdapters)和自定义基础适配器(Custom BaseAdapters)
之前我们提到,通常有两种方式来检索数据——第一种是Cursor对象的形式,第二种是对象列表的形式。在本节中,我们将关注后一种检索和处理数据的方法,以及如何将对象列表转换为可查看的数据行。
那么,在什么情况下我们实际上会有一个对象列表,而不是Cursor呢?到目前为止,我们的所有关注点都集中在构建 SQLite 数据库和内容提供者上,在所有情况下我们都返回了一个Cursor。但是,正如我们将在后续章节中看到的,数据存储实际上并不总是在移动设备端完成,而是在外部数据库完成。
在这些情况下,获取数据并不像简单地执行 SQLite 查询那么容易,而是需要通过网络通过 HTTP 请求来完成。此外,一旦获取到数据,它很可能是某种字符串格式(通常是 XML 或 JSON——关于这一点稍后会详细介绍),而不是解析这个字符串以获取数据,然后将其插入到 SQLite 数据库中,通常你会将每个字符串简单地转换为一个对象,并将其存储在标准列表中。为了处理对象列表,Android 有一种名为BaseAdapter的ListAdapter,我们将在本节中重写并剖析它。
让我们举一个简单的例子,这里有一个联系人对象列表(为了简化,我们称这个类为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()方法。
从概念上讲,BaseAdapter与CursorAdapter非常相似,区别在于我们传递并保持的是一个对象列表,而不是Cursor。这仅仅是在BaseAdapter的构造函数中完成,此时我们保存了对该对象列表的私有指针,并可以选择围绕该列表编写一系列包装方法(即getCount(), getItem()等)。同样,正如CursorAdapter类知道如何管理和遍历Cursor一样,BaseAdapter类也将知道如何给定的对象列表进行管理和遍历。
重点在于BaseAdapter的getView()方法。注意在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);
运行这段代码的结果与之前示例中的第二个截图完全一样(如预期)。
现在我们已经了解了CursorAdapters和BaseAdapters以及如何用代码实现每一个,让我们后退一步,考虑这两个类的潜在用例。
处理列表交互
在 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 对象中完成的——但这更多内容将在下一章介绍。
比较CursorAdapter和BaseAdapter
那么,在什么典型场景下,你会发现自己使用BaseAdapter而不是CursorAdapter,反之亦然?我们之前已经考虑了一些情况,但让我们花更多的时间来头脑风暴一些用例,以使你更加熟悉这两个ListAdapters以及何时在两者之间切换。
通常的规则是,当你的底层数据以Cursor的形式返回时,使用CursorAdapter;当你的数据可以以对象列表的形式返回或操作时,使用BaseAdapter。
这意味着对于大多数网络请求,当数据以长字符串的形式返回时(再次,有点提前说,但这个字符串通常会是 XML 或 JSON 格式),最好解析这个字符串并将其转换为对象。然后,这些对象可以存储在列表中,并传递给自定义的BaseAdapter。如果你调用外部 API,通常数据也会以 XML 或 JSON 格式返回,这也是通常的情况。如果想要缓存结果,则是例外情况。
缓存通常涉及在内存中更本地(或更快)的区域临时存储一些数据(对于 CPU 系统,这意味着将数据存储在 RAM 中而不是磁盘上,对于移动应用,这意味着将数据本地存储而不是通过网络持续请求外部数据)。如果你想缓存一些网络调用——无论是出于性能原因还是离线访问原因——那么建议的流程是进行你的网络请求,获取格式化的数据字符串,解析数据字符串,并将数据插入 SQLite 数据库(旨在模仿外部数据库)。然后,由于你的数据已经存储在 SQLite 数据库中,最好(也是最简单)的方法是快速查询并获取一个Cursor。
那么,如果你有一个静态的基本对象列表,比如字符串,该怎么办呢?如果你有一个预定义选项列表的固定目录,这通常就是这种情况。在这种情况下,BaseAdapter和CursorAdapter都显得过于复杂,你应该选择使用一种更简单的适配器,即ArrayAdapter。我尽量不花时间在这种ListAdapter上,因为它的使用非常简单,概念上也非常简单——如果你有一个静态的字符串数组,并且你想从中创建一个列表,只需将这个数组传递给ArrayAdapter即可。
然而,关于ArrayAdapter我就说这么多,我邀请你阅读以下网站的示例:
developer.android.com/resources/tutorials/views/hello-listview.html
否则,请记住,对于轻量级的静态数据,使用ArrayAdapter;对于动态的面向对象数据,使用BaseAdapter;对于基于本地存储的子表数据,使用CursorAdapter。
概述
在本章中,我们最终将焦点从后端转移到了前端——深入了解了我们可以将数据绑定到用户界面的方法。当然,用户可以通过多种方式与数据交互,但迄今为止最常见的方式是通过ListView。
ListViews和ListActivities是非常方便的类,它们允许我们将ListAdapters绑定到 Activity,进而绑定到列表布局,处理诸如用户触摸列表中某行的事件。ListAdapters是那些接收底层数据并为你处理绑定过程的类——也就是说,当你的列表上下滚动时,你无需跟踪列表中的位置;所有这些工作都在幕后完成。相反,你需要做的就是根据你拥有的底层数据类型选择合适的ListAdapter,并指定你希望如何进行绑定。
配备了这些ListAdapters,我们能够重新创建一个简化版的联系人列表,更重要的是,我们得以初步了解所有将数据以交互式、美观的方式展现出来的方法。
在本章的最后,我们思考了ListAdapters每个子类的使用场景(总共看到了三个不同的子类,分别是CursorAdapter、BaseAdapter以及最后的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的筛选器。在这两种情况下,用户命中了你的缓存,从而为你节省了耗时的网络请求。
设计缓存系统的最后一步就是确定多久刷新一次缓存。从不刷新缓存是不理想的,因为随着时间的推移,每次你缓存新请求,它只会消耗更多的内存,而且,你还会遇到数据过时的问题。例如,假设用户对其家乡的咖啡馆/咖啡店发出请求,你缓存了这个结果。然而,你的缓存系统从不刷新缓存。一年内很多事情都可能发生,一年后用户再次拿出你的应用并发出同样的请求,他/她将会命中缓存并获取旧数据,而不是发出新的请求。
另一方面,如果允许你的缓存太频繁地刷新,你将降低缓存命中频率,最终不得不进行比预期更多的网络请求。因此,我们再次面临一个优化问题,我们希望最大化缓存命中次数,同时最小化所需的内存消耗,并最小化我们拉取陈旧数据的频率。
这个简化的优化问题位于每个缓存系统的核心,当你使用本地数据库来缓存外部数据库网络请求时,这是你需要牢记的问题。尽管关于缓存还有很多可以讨论的内容,但本节(以及整章)的目标是激发你的思考过程,并介绍本地数据库的众多用途之一,以及它们如何与外部数据库结合使用。
在下一节中,我将讨论一个典型的以数据为中心的应用程序的外观,以及典型的数据流程。再次强调,我所说的以数据为中心的应用程序是指那些主要功能涉及显示/与某种形式的数据进行交互的应用程序。这可以包括从社交网络应用程序(用户可以相互阅读/发送消息,这里的数据包括消息、事件、照片——任何可以共享的内容),到餐饮应用程序(用户可以加载附近餐厅的详细信息)。这通常不包括许多基于游戏的应用程序,尽管即使是基于游戏的应用程序有时也需要采用某种外部数据库(例如,我们之前讨论的全局高分表)。接下来,让我们再次调整焦点,从更全面的角度思考移动应用程序——作为外部数据库和外部应用程序的扩展,而不仅仅是简单的独立应用程序。
典型应用程序设计
迄今为止,我们只讨论了关于后端应用程序设计的想法。我们首先考虑了完全本地化的后端与完全外部后端使用的优缺点,然后考虑了在应用程序中使用两者的方法,试图两全其美。我们可以这样做的一种方式是使用缓存,仅在设计缓存时,我们就发现有许多设计决策必须被做出。
不论你是否意识到,这段时间你一直在分析不同应用程序的不同后端设计的优缺点,现在我们准备关注一个非常通用且极其实用的设计,这种设计在以数据为中心的移动应用程序中经常使用。但是关于文字就到此为止,让我们给我们的设计配上图片:
那么这里到底发生了什么?让我们来分解一下:
-
首先,我们有我们的外部集中式数据库。这是我们后端的核心。所有应用程序(无论是网页还是移动应用)都将引用这个数据库,这样我们可以确保所有移动设备上的数据都是同步且最新的。此外,在这种设计中,我们的应用程序不再是特定于某个平台的。换句话说,可以轻松创建一个在所有移动设备上都能运行的应用程序,包括 Android 和 iOS,因为所有设备都指向同一个数据库。
-
外部数据库还将客户端(即移动应用和网页应用)与数据收集/解析/清洗端分离。在这里,后者包含了所有旨在收集、解析和清洗后端数据的流程。这可能包括定期调用 API(假设 API 允许你存储其数据的副本)、抓取网页(稍后讨论)、或者在有些情况下手动插入新数据。一旦数据进来,通常需要被解析和清洗以符合你的数据库规格。此外,通过使用 CRON 作业(在第九章中讨论的收集和存储数据),整个数据收集和清洗的过程本身也可以自动化。因此,以这种方式设置你的应用程序,你可以将所有这些后台数据挖掘工作从用户面前隐藏起来。
-
另一方面,网页应用和移动应用在不断地向你的外部数据库发送请求。这些请求通常以 HTTP GET 和 POST 请求的形式(获取数据与插入/更新数据),并以 XML 或 JSON 格式返回结果。再次强调,由于这些只是标准的 HTTP 网络请求,因此与发起请求的平台无关,你可以轻松地将应用程序从一个平台移植到另一个平台。
-
最后,我们有缓存,这是外部数据库的一个临时的、局部的子集,存在于移动/网页应用端。如早前讨论的,这些缓存设计用来通过避免重复的网络请求来提高应用程序的性能。
这就是我们要介绍的内容。再次强调,目前这还是一个很高的层面,但我们已经看到了并讨论了与我们的设计第四部分相关的组件,在接下来的章节中,我们还将看看前三个部分。
总结
尽管在本章中我们没有查看任何代码,但我们仍然完成了很多工作。我们通过确定两个非常现实的需求(一个简单的高分榜和一个位置/场所数据库)开始本章,并走过了选择合适存储方法的思考过程。
我们发现,对于像高分排行榜这样简单的功能,一个本地化的 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 BY和LIMIT。这样,对于那些只熟悉像 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 数据库允许你通过调用PersistenceManager的getObjectById()方法,快速检索具有唯一键或 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 源代码的复杂性有关,但在高层次上,它涉及三个步骤:
-
获取所需的 URL,建立与 URL 的连接,并获取其源代码。
-
组织和清理底层源代码,使其成为一个有效的 XML 文档。
-
运行像 XPath(或 XQuery 和 XSLT)这样的树遍历语言,以及/或使用正则表达式(REGEX)来解析所需的节点。
第一步相对容易理解,但我需要指出一点。通常你会发现需要抓取某种动态网页,这意味着 URL 不是静态的,可能会根据日期、一组标准等而变化。让我们通过两个例子来解释我的意思。
第一个涉及股票。假设你正在尝试编写一个可以抓取给定股票当前价格的网页抓取器,比如从 Yahoo! Finance 获取。那么,首先,URL 是什么样子的?快速检查谷歌(股票代码为 GOOG)的当前价格,我们看到相应网页的 URL 是:
这是一个相当简单的 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,看起来是结束日期,它们似乎有键值a到f。在这种情况下,键值并不是最直观的,而且很多时候你会看到键值为day或d以及month或m。然而,这个想法仅仅是通过这个 URL,你不仅可以动态调整股票代码,还可以根据用户需要查看的日期范围来调整这些日期。牢记这个想法,你会逐渐学会如何更好地解读各种 URL,并学会如何使它们极具动态性,适合你的抓取需求。
现在,一些网站通过 POST 请求发送请求。不同之处在于,在 POST 请求中,参数嵌入在请求中(而不是嵌入在 URL 中)。这样,潜在的私人数据就不会在 URL 中明显显示(尽管这只是使用 POST 请求的一个用例)。那么当这种情况发生时,我们应该怎么做呢?嗯,没有特别简单的方法。通常,你需要下载一个 HTTP 请求监听器(对于像 Chrome 和 Firefox 这样的浏览器,只需搜索 HTTP 请求监听器插件)。这将允许你看到正在进行的请求(包括 GET 和 POST 请求),以及传递的参数。一旦你知道了参数是什么,其余的工作就像 GET 请求一样进行。
现在,当我们有了 URL 之后,下一步就是获取底层的源代码并将其结构化。当然,自己来做这件事可能会很痛苦,但幸运的是,有一些库可以帮助我们清理和结构化源代码。我经常使用的一个库叫做HtmlCleaner,可以在以下 URL 找到:
这是一个很棒的库,它提供了清理和结构化源代码的方法,导航生成的 XML 文档,最终解析 XML 节点的值和属性。一旦我们的数据被清理,最后一步就是简单地遍历树并挑选出我们想要的数据片段。现在,说起来容易做起来难,因为仅使用 Java 及其本地包并没有真正简单的方法来系统地和可靠地遍历树。我所说的系统性和可靠性是指即使底层源代码的结构稍微有所变化,也能够遍历树并解析正确的数据。
例如,假设你的解析方法简单到告诉代码获取第五个节点的值。那么当 Yahoo!(或你正在抓取的任何网站)决定在其网站上添加一个新标题,现在第五个节点变成了第六个节点时,会发生什么?即使在这种对底层网站的相对简单的更改下,你的爬虫也会崩溃,并开始从错误的节点返回值,因此,我们理想上希望找到一种方法,无论底层网站如何变化,都能获取正确的节点值。
幸运的是,前端工程师经常会构建这样的网站:重要字段会拥有带有唯一值的class或id属性的标签。我们可以利用这些有帮助的、描述性的命名约定,使用一种名为XPath的便捷语言。一旦你了解了它,这门语言本身是相当容易解释的;实际上,它的语法类似于任何路径(即目录路径、URL 路径等),所以如果你愿意,我可以直接让你访问以下 URL 来了解其细节:
无论如何,现在只需记住 XPath 是一种允许你通过路径返回节点集的简单语言。XPath 的特殊之处在于,在路径中,你可以通过包含各种过滤器来进一步细化搜索,例如,只返回特定class的div。这就是具有描述性的class和id属性发挥作用的地方,因为我们可以深入 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"> </div>
<div id="boxPopup"> </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 & 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"> </div>
</div>
<div class="movieOptions">
<div id="movieRating" class="ratingWidget">
</div>
</div>
...
但是请注意,这段源代码是截至我撰写本文时的,不能保证保持不变。然而,从上面的源代码中,我们可以看到每个游戏都列在一个带有类addToQueueEligible game sizeb gb6 bvr-gamelistitem的div标签中。这是一个相当长的类名,但我们可以确信,通过搜索带有这个类标签的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
以下是给那些在移动中阅读此内容的读者的截图:
现在,让我们回到 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 请求。你可以使用传统的Runnable和Handler类来实现这一点,但 Android 也为你提供了很好的封装类,如AsyncTask类。我还鼓励你阅读谷歌朋友的这篇文章,了解更多关于设计响应式应用的信息:
developer.android.com/guide/practices/design/responsiveness.html
所以现在,我们已经发出了 GET 请求,解析了数据,并且在移动端拥有了一个漂亮的VideoGame对象列表,这些对象是我们从服务器接收到的VideoGame对象的副本。剩下要做的就是使用我们在书中前面看到的ListAdapter之一并将其绑定到 UI!
最后的步骤:绑定到 UI(再次)
现在是最后一步——将数据绑定到用户界面。对于那些已经阅读完全书的读者来说,这一部分应该非常熟悉,因此我会尽量简洁但全面的讲解。
在前面的章节中,我们实际上已经将所有网络请求连接在一起,无论是在应用端还是在服务器端,因此现在我们应该能够从任何移动应用无缝地发出 GET 请求。我们还研究了如何解析返回的响应(这同样作为一个练习留给你,因为响应可能以任何方式返回)并将数据从字符串形式转换回VideoGame对象形式。
现在让我们回顾一下第六章,绑定到 UI。在那章中,我们了解了ListAdapters的两个子类:BaseAdapter和CursorAdapter。您还记得,当我们的数据存储在 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);
}
}
完成后,我们的最终结果如下所示:
完成了!给自己点个赞,因为我们刚刚完成了一个完整的数据中心应用程序!现在,我们不仅拥有一个功能齐全的后端,配备了自己的 HTTP 请求集合,而且还构建了一个有前景的 Android 应用程序,它能向后端发起 HTTP 请求,获取结果,并以简单的列表形式展示。
概述
我们已经到达了结尾,但在我们结束之前,让我们从开始到结束看看我们所学习和涵盖的所有令人惊叹的事情。我们通过查看 Android 上的各种本地存储方法开始了这本书——这些方法非常轻量级和高效,还有像 SQLite 数据库这样更复杂但同时也更强大的方法。
我们接着深入探讨了 SQLite 数据库——这可能是你在 Android 应用开发职业生涯中遇到的最常见的本地数据存储形式,然后进入了第三章,SQLite 查询。接下来,我们学习了如何通过将 SQLite 数据库包装在内容提供者中来向外部应用公开这些数据库。然后我们研究了 Android 操作系统中最受欢迎的内容提供者——联系人内容提供者,并实现了一些可能会遇到的一些常见查询。
在完全掌握了本地存储方法之后,我们继续学习如何通过各种ListAdapter类将这些本地数据源绑定到用户界面上。在这一章节中,我们看到了CursorAdapter和BaseAdapter的实现和使用场景。
从那里,我们转向了以数据为中心的应用程序设计和编程的更全面视角。我们讨论了实际使用各种本地数据存储形式的方法,并引入了缓存概念,这是 SQLite 数据库的一个极其实用的用例。这自然引导我们考虑外部数据库,因为缓存通常与网络请求和网络编程密切相关。
我们以外部数据库结束了本书。我们讨论了可以使用的外部数据库的不同类型,并决定在示例实现中使用 Google App Engine (GAE)。使用 GAE,我们实现了一个完全功能的 JDO 数据库(全部在云端完成),此时我们还实现了一系列 HTTP Servlet,使我们能够进行 HTTP GET 和 POST 请求。最后,我们通过实现应用程序移动端的代码结束了本书——这让我们回到了 Android,并圆满完成了整个学习过程。
我希望通过这些内容,我们能更好地理解数据库(无论是本地的还是外部的)是如何融入开发强大、以数据为中心的 Android 应用程序的整体架构中的。祝你好运,开发愉快。