原文:
zh.annas-archive.org/md5/178BF5D3B8A98AFC3DB2CE2ED8D821E4
译者:飞龙
前言
今天,我们生活在一个日益以数据和数据驱动为中心的世界。我们生活在一个像亚马逊这样的公司追踪我们查看的每个商品和我们购买的每个商品,以便向我们推荐类似产品的世界。我们生活在一个像谷歌这样的公司存储投向它们的每个搜索查询,以便在未来推荐更好的搜索查询的世界。我们生活在一个像 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 卡根目录的文件路径。在这一点上,我们只需要创建我们的新文件并实例化一个FileWriter
和BufferedWriter
,然后将我们的字符串写入文件。这里需要注意的是,处理外部存储时写入磁盘的方法与我们之前写入内部存储的磁盘方法不同。
这实际上是一个需要注意和理解的重要点,这就是为什么我如此强调这些写入方法。在内部存储示例中,我们通过调用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 传递到第四个参数中。
最后三个参数(groupBy
、having
和orderBy
)对于熟悉 SQL 的人来说应该很有意义,但对于那些不熟悉的人,以下是每个参数的快速解释:
-
groupBy
- 添加这个功能可以让你按照指定的列对结果进行分组。如果你需要获取例如课程 ID 和该课程学生数量的表格,这个功能将非常有用:只需在Class
表中按课程 ID 分组即可实现这一点。 -
having
- 与groupBy
子句结合使用,这个子句允许你过滤聚合后的结果。假设你在Class
表中按课程 ID 分组,并希望过滤掉所有注册学生少于 10 人的班级,你可以使用having
子句来实现这一点。 -
orderBy
- 这是一个相当直观的子句,orderBy
子句允许我们按指定列(们)以及升序或降序对查询结果子表进行排序。例如,假设你想按成绩和姓名对Students
表进行排序——指定一个orderBy
子句将允许你这样做。
最后,在两个query()
变体中,你会看到增加了limit
和distinct
参数: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
的输出结果:
看吧!我们很快发现学生 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 已经从列表中移除。输出结果如下:
事实上,我们可以看到一开始我同时选了 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
是选择给定表特定列的标准查询
下面是我们使用之前架构中的一些命令的例子:
希望这能帮助您开始,但若要获取 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)
使用包含distinct
和limit
约束参数的替代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 查询 - 但我们稍后会玩转这个功能。
现在,让我们看看这些查询的结果,并验证这些结果是否正确:
在我看来效果很不错!我们可以看到,每种方法都能如预期那样返回我们表格的所有行。在第三种方法下,我们还可以看到使用我们的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
类返回的构建查询中明确看到:
)
与任何编程语言一样,你可以通过使用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
子句:
LIMIT
子句仅仅允许您限制要返回的行数。LIMIT
有两种格式:
DISTINCT
和LIMIT
子句
因此,我们的朋友 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
子句有意义,一个列必须在您的查询中被指定。
对于这个查询的结果是:
这确实是我们当前表的案例!最后但同样重要的是,让我们来看看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 查询给我们的多用途性,是交换灵活性和便利性以及抽象性的完美示例。
是的,看起来不错!在所有方法中,尽管我们没有指定任何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
在第一个方法中,我们看到了这个语法的实际应用,然后在后两个方法中,我们看到只需将列名后跟ASC
或DESC
(作为字符串)传递给相应查询方法的ORDERBY
参数。在后两个方法中,语法本质上相同,因此我在这里不过多赘述,但重要的是要了解 SQL ORDERBY
子句的组成部分。在展示的所有三种方法中,我们都是通过州(state)列对结果子表进行排序,因此为了验证我们的查询,我们检查输出并看到以下内容:
事实上,我们可以看到,结果行是按照州(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()
函数也可以接受一个*
作为参数,它只是返回子表中所有行的计数。
现在,让我们看看我们的输出是什么:
看吧!正如我们所预期的——我们的查询返回了每个状态以及它们各自的出现频率!
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:
很完美——非常直观。之前我们遇到了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);
}
所以,这里我们使用这三种方法中的每一种来测试不同的聚合函数。结果如下所示:
引用之前表的状况后,你可以快速验证输出的数字,并确认这些函数确实在按预期工作。除了聚合函数(通常用于数值类型的列),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);
再次,以下是这些核心函数的相关输出:
现在,关于在 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, classIdCol
和classCourseIdCol
时所做的事情,语法如下:
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
语句的输出如下:
这样你就可以立即看到查询的语法以及结果。
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
语句执行相同的过滤。我们可以验证输出的行是相同的,同时比较两种方法的速度:
我们可以看到,这里性能提升了近 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();
让我们看看我们做得如何:
所以在这种情况下,我们看到了性能的提升,但不太明显,效率提高了 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 函数时,性能提高了 16 倍。
总结
在本章中,我们从关注 Android OS 开始,查看了可用哪些类型的查询方法。我们了解到,与 SQLite 数据库交互有三种众所周知的方式,有些方式比其他方式更“方便”,有些则更灵活、更强大。
然而,我们也看到,尽管每种方法都有其优缺点,但所有三种查询方法最终都能执行相同的查询,只是使用了不同的语法集或不同的参数集。这时,我们从这些方法本身转移开,更多地关注查询本身,从简单的查询开始,这些查询从最基本的SELECT
查询到允许您指定特定列和行的更复杂的查询。后来,我们讨论了更高级的查询,这些查询从ORDERBY
和GROUPBY
查询到最复杂、最深入的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 的 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,它就会返回该路径关联的标志(在我们的例子中就是CITIZENS
或SSID
),此时我们可以使用switch
语句来导航到正确的功能。
一旦你理解了高级层面的操作,其余部分现在应该相当直接和熟悉。如果用户刚刚提交了一个常规查询(即带有CITIZENS
标志的查询),那么我们需要做的就是定义投影映射和将被查询的表名。再次强调,如果用户想要直接访问我们表中的某一行,那么通过在路径中指定社会保险 ID,我们可以使用以下这行代码解析出该公民信息:
String ssid =
uri.getPathSegments().get(CitizenTable.SSID_PATH_POSITION);
不用太担心SSID_PATH_POSITION
变量——我们在这里所做的就是获取传入的 URI 并将其分解为路径段。一旦有了路径段,我们将获取第一个路径段(随后SSID_PATH_POSITION
被设置为1
,我们很快就会看到),因为在我们的示例中,只会有一个路径段传入。
现在,一旦我们获得了查询中传入的期望的社会保险 ID,我们需要做的就是将其附加到WHERE
过滤器上,其余部分就是我们之前看到的内容——获取可读数据库,并填充SQLiteDatabase
的query()
方法。
最后我要提到的是,在成功发起查询并获取指向数据的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
因此,为了验证我们的结果,让我们看看输出的内容:
从之前的截图中,我们可以看到两个查询确实输出了正确的数据行!
关于上一个例子,我要说的最后一件事(因为大部分语法和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
内容提供者,它的架构,以及如何与它互动以完成标准查询和更新。