Android_ContentProvider

为了跨程序共享数据

对于Android数据持久化技术,包括文件存储、SharedPreferences存储以及数据库存储,这些持久化技术所保存的数据都只能在当前应用程序中访问。虽然文件和SharedPreferences存储中提供了 MODE_WORLD_READABLEMODE_WORLD_WRITEABLE这两种操作模式,用于供给其他的应用程序访问当前应用的数据,但这两种模式在Android 4.2版本中都已被废弃了。因为Android官方已经不再推荐使用这种方式来实现跨程序数据共享的功能,而是应该使用更加安全可靠的**内容提供器(ContentProvider)**技术。

为什么要将我们程序中的数据共享给其他程序呢?当然,这个是要视情况而定的,比如说账号和密码这样的隐私数据显然是不能共享给其他程序的,不过一些可以让其他程序进行二次开发的基础性数据,我们还是可以选择将其共享的。例如系统的电话簿程序,它的数据库中保存了很多的联系人信息,如果这些数据都不允许第三方的程序进行访问的话,恐怕很多应用的功能都要大打折扣了。除了电话簿之外,还有短信、媒体库等程序都实现了跨程序数据共享的功能,而使用的技术当然就是内容提供器了,下面就来对这一技术进行深入的探讨。

运行时权限

内容提供器(Content Provider)主要用于在不同的应用程序之间实现数据共享的功能,它提供了一套完整的机制,允许一个程序访问另一个程序中的数据,同时还能保证被访数据的安全性。

目前,使用内容提供器是Android实现跨程序共享数据的标准方式。

不同于文件存储和SharedPreferences存储中的两种全局可读写操作模式,内容提供器可以选择只对哪一部分数据进行共享,从而保证程序中的隐私数据不会有泄漏的风险。

在正式开始学习内容提供器之前,需要先掌握另外一个非常重要的知识——Android运行时权限,因为内容提供器会使用到运行时权限的功能。当然不光是内容提供器用到。

Android的权限机制从第一个版本开始就已经存在了。但其实之前Android的权限机制在保护用户安全和隐私等方面起到的作用比较有限,尤其是一些大家都离不开的常用软件,非常容易“店大欺客”。为此,Android开发团队在Android 6.0系统中引用了运行时权限这个功能,从而更好地保护了用户的安全和隐私。

Android权限机制详解

首先来看一下过去Android的权限机制是什么样的。比如为了要访问系统的网络状态以及监听开机广播,于是在AndroidManifest.xml文件中添加了这样两句权限声明:

1
2
3
4
5
6
7
8
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.example.broadcasttest">
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED">
</uses-permission>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE">
</uses-permission>
...
</manifest>

因为访问系统的网络状态以及监听开机广播涉及了用户设备的安全性,因此必须在AndroidManifest.xml中加入权限声明,否则程序就会崩溃。

那么现在问题来了,加入了这两句权限声明后,对于用户来说到底有什么影响呢?为什么这样就可以保护用户设备的安全性了呢?

其实用户主要在以下两个方面得到了保护,一方面,如果用户在低于6.0系统的设备上安装该程序,会在安装界面给出提醒——“此应用将获得以下权限:查看网络连接、开机启动”。这样用户就可以清楚地知晓该程序一共申请了哪些权限,从而决定是否要安装这个程序。

另一方面,用户可以随时在应用程序管理界面查看任意一个程序的权限申请情况。这样该程序申请的所有权限就尽收眼底,什么都瞒不过用户的眼睛,以此保证应用程序不会出现各种滥用权限的情况。

这种权限机制的设计思路其实非常简单,就是用户如果认可你所申请的权限,那么就会安装程序,如果不认可申请的权限,那么拒绝安装就可以了。

但是理想是美好的,现实却很残酷,因为很多我们所离不开的常用软件普遍存在着滥用权限的情况,不管到底用不用得到,反正先把权限申请了再说。比如说微信所申请的权限。

Android开发团队当然也意识到了这个问题,于是在6.0系统中加入了运行时权限功能。也就是说,用户不需要在安装软件的时候一次性授权所有申请的权限,而是可以在软件的使用过程中再对某一项权限申请进行授权。比如说一款相机应用在运行时申请了地理位置定位权限,就算我拒绝了这个权限,但是我应该仍然可以使用这个应用的其他功能,而不是像之前那样直接无法安装它。

当然,并不是所有权限都需要在运行时申请,对于用户来说,不停地授权也很烦琐。Android现在将所有的权限归成了两类,一类是普通权限,一类是危险权限。准确地讲,其实还有第三类特殊权限,不过这种权限使用得很少,因此不在讨论范围之内。普通权限指的是那些不会直接威胁到用户的安全和隐私的权限,对于这部分权限申请,系统会自动帮我们进行授权,而不需要用户再去手动操作了。危险权限则表示那些可能会触及用户隐私或者对设备安全性造成影响的权限,如获取设备联系人信息、定位设备的地理位置等,对于这部分权限申请,必须要由用户手动点击授权才可以,否则程序就无法使用相应的功能。

但是Android中有一共有上百种权限,怎么从中区分哪些是普通权限,哪些是危险权限?其实不难,危险权限很少,除了危险权限之外,剩余的就是普通权限。下表为到Android 10为止所有的危险权限。

权限组名 权限名
CALENDAR READ_CALENDAR
WRITE_CALENDAR
CALL_LOG READ_CALL_LOG
WRITE_CALL_LOG
PROCESS_OUTGOING_CALLS
CAMERA CAMERA
CONTACTS READ_CONTACTS
WRITE_CONTACTS
GET_ACCOUNTS
LOCATION ACCESS_FINE_LOCATION
ACCESS_COARSE_LOCATION
ACCESS_BACKGROUND_LOCATION
MICROPHONE RECORD_AUDIO
PHONE READ_PHONE_STATE
READ_PHONE_NUMBERS
CALL_PHONE
ANSWER_PHONE_CALLS
ADD_VOICEMAIL
USE_SIP
ACCEPT_HANDOVER
SENSORS BODY_SENSORS
ACTIVITY_RECOGNITION ACTIVITY_RECOGNITION
SMS SEND_SMS
RECEIVE_SMS
READ_SMS
RECEIVE_WAP_PUSH
RECEIVE_MMS
STORAGE READ_EXTERNAL_STORAGE
WRITE_EXTERNAL_STORAGE
ACCESS_MEDIA_LOCATION

每当要使用一个权限时,可以先到这张表中来查一下,如果是属于这张表中的权限,那么就需要进行运行时权限处理,如果不在这张表中,那么只需要在AndroidManifest.xml文件中添加一下权限声明就可以了。

另外注意一下,表格中每个危险权限都属于一个权限组,我们在进行运行时权限处理时使用的是权限名,但是用户一旦同意授权了,那么该权限所对应的权限组中所有的其他权限也会同时被授权。但是谨记,不要基于此规则来实现任何功能逻辑,因为Android系统随时可能调整权限的分组。

在程序运行时申请权限

首先新建一个RuntimePermissionTest项目。先以CALL_PHONE这个权限作为示例。

CALL_PHONE这个权限是编写拨打电话功能的时候需要声明的,因为拨打电话会涉及用户手机的资费问题,因而被列为了危险权限。在Android 6.0系统出现之前,拨打电话功能的实现非常简单,修改activity_main.xml布局文件,如下所示:

1
2
3
4
5
6
7
8
9
10
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<Button
android:id="@+id/make_call"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Make Call">
</Button>
</LinearLayout>

修改MainActivity中的代码,如下:

Java版:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class MainActivity extends AppCompatActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
Button makeCall = (Button) findViewById(R.id.make_call);
makeCall.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
try {
Intent intent = new Intent(Intent.ACTION_CALL);
intent.setData(Uri.parse("tel:10086"));
startActivity(intent);
} catch (SecurityException e) {
e.printStackTrace();
}
}
});
}
}

kotlin版:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
makeCall.setOnClickListener {
try {
val intent = Intent(Intent.ACTION_CALL)
intent.data = Uri.parse("tel:10086")
startActivity(intent)
} catch (e: SecurityException) {
e.printStackTrace()
}
}
}
}

可以看到,在按钮的点击事件中,构建了一个隐式Intent,Intent的action指定为Intent.ACTION_CALL,这是一个系统内置的打电话的动作,然后在data部分指定了协议是tel,号码是10086。要注意区分,Intent.ACTION_DIAL表示打开拨号界面,这个是不需要声明权限的,而Intent.ACTION_CALL则可以直接拨打电话,因此必须声明权限。

另外为了防止程序崩溃,将所有操作都放在了异常捕获代码块当中。那么接下来修改AndroidManifest.xml文件,在其中声明如下权限:

1
2
3
4
5
6
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.example.filetest">
<uses-permission android:name="android.permission.CALL_PHONE">
</uses-permission>
...
</manifest>

这样拨打电话的功能成功实现了,在低于Android 6.0系统的手机上都是可以正常运行的,但是如果在6.0或者更高版本系统的手机上运行,点击Make Call按钮就没有任何效果,这时观察logcat中的打印日志,会看到错误信息中提醒我们“Permission Denial”,可以看出,这是权限被禁止所导致,因为Android 6.0及以上系统在使用危险权限时必须进行运行时权限处理。

修改MainActivity中的代码,如下:

Java代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
public class MainActivity extends AppCompatActivity {
@Override

protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
Button makeCall = (Button) findViewById(R.id.makeCall);
makeCall.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
if(ContextCompat.checkSelfPermission(MainActivity.this,
Manifest.permission.CALL_PHONE) != PackageManager.PERMISSION_GRANTED) {
ActivityCompat.requestPermissions(MainActivity.this, new String[]{ Manifest.permission.CALL_PHONE }, 1);
} else {
call();
}
}
});
}
private void call() {
try {
Intent intent = new Intent(Intent.ACTION_CALL);
intent.setData(Uri.parse("tel:10086"));
startActivity(intent);
} catch (SecurityException e) {
e.printStackTrace();
}
}
// 用户选择同意或拒绝后,会回调到此方法中,授权的结果会封装在grantResults参数中。
@Override
public void onRequestPermissionsResult(int requestCode, String[] permissions,
int[] grantResults) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
switch (requestCode) {
case 1:
if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
call();
} else {
Toast.makeText(this, "You denied the permission", Toast.LENGTH_SHORT).show();
}
break;
default:

}
}
}

kotlin版:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
makeCall.setOnClickListener {
if(ContextCompat.checkSelfPermission(this,
Manifest.permission.CALL_PHONE) != PackageManager.PERMISSION_GRANTED) {
ActivityCompat.requestPermissions(this,
arrayOf(Manifest.permission.CALL_PHONE), 1)
}
else {
call()
}
}
}
override fun onRequestPermissionsResult(requestCode: Int,
permissions: Array<String>,
grantResults; IntArray) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
when (requestCode) {
1 -> {
if (grantResults.isNotEmpty() &&
grantResults[0] == PackManager.PERMISSION_GRANTED) {
call()
} else {
Toast.makeText(this, "You denied the permission",
Toast.LENGTH_SHORT).show()
}
}
}
}
private fun call() {
try {
val intent = Intent(Intent.ACTION_CALL)
intent.data = Uri.parse("tel:10086")
startActivity(intent)
} catch (e: SecurityException) {
e.printStackTrace()
}
}
}

上面的代码将运行时权限的完整流程都覆盖了,下面来具体解析一下。说白了,运行时权限的核心就是在程序运行过程中由用户授权去执行某些危险操作,程序是不可以擅自做主去执行这些危险操作的。因此,第一步就是要先判断用户是不是已经给过授权了,借助的是ContextCompat.checkSelfPermission方法。checkSelfPermission方法接收两个参数,第一个参数是Context,第二个参数是具体的权限名,比如打电话的权限名就是Manifest.permission.CALL_PHONE,然后我们使用方法的返回值和PackageManager.PERMISSION_GRANTED做比较,相等就说明用户已经授权,不等就表示用户没有授权。

如果已经授权的话就简单了,直接去执行拨打电话的逻辑操作就可以了,这里我们把拨打电话的逻辑封装到了call方法当中。如果没有授权的话,则需要调用ActivityCompat.requestPermissions方法来向用户申请授权,requestPermissions方法接收3个参数,第一个参数要求是Activity的实例,第二个参数是一个String数组,我们把要申请的权限名放在数组中即可,第三个参数是请求码,只要是唯一值就可以了,这里传入了1。

调用完了requestPermissions方法之后,系统会弹出一个权限申请的对话框,然后用户可以选择同意或拒绝我们的权限申请,不论是哪种结果,最终都会回调到onRequestPermissionsResult方法中,而授权的结果则会封装在grantResults参数当中。这里我们只需要判断一下最后的授权结果,如果用户同意的话就调用call方法来拨打电话,如果用户拒绝的话我们只能放弃操作,并且弹出一条失败提示。

现在重新运行一下程序,并点击Make Call按钮。由于用户还没有授权过我们拨打电话权限,因此第一次运行会弹出一个权限申请的对话框,用户可以选择同意或者拒绝。

如果选择同意,就成功进入到拨打电话界面了,由于用户已经完成了授权操作,之后再点击Make Call按钮就不会再弹出权限申请对话框了,而是可以直接拨打电话。那可能你会担心,万一以后我又后悔了怎么办?没有关系,用户随时都可以将授予程序的危险权限进行关闭,进入Settings/Apps/RuntimePermissionTest/Permissions。在这里我们就可以对任何授予过的危险权限进行关闭了。

访问其他程序中的数据

内容提供器的用法一般有两种,一种是使用现有的内容提供器来读取和操作相应程序中的数据,另一种是创建自己的内容提供器给程序的数据提供外部访问接口。

如果一个应用程序通过内容提供器对其数据提供了外部访问接口,那么任何其他的应用程序就都可以对这部分数据进行访问。Android系统中自带的电话簿、短信、媒体库等程序都提供了类似的访问接口,这就使得第三方应用程序可以充分地利用这部分数据来实现更好的功能。

ContentResolver的基本用法

对于每一个应用程序来说,如果想要访问内容提供器中共享的数据,就一定要借助ContentResolver类,可以通过Context中的getContentResolver方法获取到该类的实例。

ContentResolver中提供了一系列的方法用于对数据进行CRUD操作,其中insert方法用于添加数据,update方法用于更新数据,delete方法用于删除数据,query方法用于查询数据。SQLiteDatabase中也是使用这几个方法来进行CRUD操作的,只不过它们在方法参数上稍微有一些区别。

不同于SQLiteDatabase,ContentResolver中的增删改查方法不接收表名参数,而是使用一个Uri参数代替,这个参数被称为内容URI。内容URI给内容提供器中的数据建立了唯一标识符,它主要由两部分组成:authority和path。(其实还有一个固定的部分,是头部协议声明)

authority是用于对不同的应用程序做区分的,一般为了避免冲突,都会采用程序包名的方式来进行命名。比如某个程序的包名是com.example.app,那么该程序对应的authority就可以命名为com.example.app.provider

path则是用于对同一应用程序中不同的表做区分的,通常都会添加到authority的后面。比如某个程序的数据库里存在两张表:tablel和table2,这时就可以将path分别命名为/tablel/table2,然后把authoritypath进行组合,内容URI就变成了com.example.app.provider/table1com.example.app.provider/table2。不过,目前还很难辨认出这两个字符串就是两个内容URI,我们还需要在字符串的头部加上协议声明。因此,内容URI最标准的格式写法如下:

content://com.example.app.provider/tablelcontent://com.example.app.provider/table2

发现,内容URI可以非常清楚地表达出我们想要访问哪个程序中哪张表里的数据。也正是因此,ContentResolver中的增删改查方法才都接收Uri对象作为参数,因为如果使用表名的话,系统将无法得知我们期望访问的是哪个应用程序里的表。

在得到了内容URI字符串之后,我们还需要将它解析成Uri对象才可以作为参数传入。解析的方法也相当简单,只需要调用Uri.parse方法,代码如下所示:Uri uri = Uri.parse("content://com.example.app.provider/table1")。现在我们就可以使用这个Uri对象来查询table1表中的数据了,代码如下所示:

1
2
3
4
5
6
Cursor cursor = getContentResolver().query(
uri,
projection,
selection,
selectionArgs,
sortOrder);

这些参数和SQLiteDatabase中query方法里的参数很像,但总体来说要简单一些,毕竟这是在访问其他程序中的数据,没必要构建过于复杂的查询语句。

query方法的参数说明:

query方法参数 对应SQL部分 描述
uri from table_name 指定查询某个应用程序下的某一张表
projection select column1, column2 指定查询的列名
selection where column = value 指定where的约束条件
selectionArgs - 为where中的占位符提供具体的值
sortOrder order by column1, column2 指定查询结果的排序方式

查询完成后返回的仍然是一个Cursor对象,这时我们就可以将数据从Cursor对象中逐个读取出来了。读取的思路仍然是通过移动游标的位置来遍历Cursor的所有行,然后再取出每一行中相应列的数据,代码如下所示:

1
2
3
4
5
6
7
if(cursor != null) {
while (cursor.moveToNext()) {
String column1 = cursor.getString(cursor.getColumnIndex("column1"));
int column2 = cursor.getInt(cursor.getColumnIndex("column2"));
}
cursor.close();
}

向table1表添加数据,代码如下:

1
2
3
4
ContentValues values = new ContentValues();
values.put("column1", "text");
values.put("column2", 1);
getContentResolver().insert(uri, values);

可以看到,仍然是将待添加的数据组装到ContentValues中,然后调用ContentResolver的insert方法,将Uri和ContentValues作为参数传入即可。

现在如果我们想要更新这条新添加的数据,把column1的值清空,可以借助ContentResolver的update方法实现。代码如下所示:

1
2
3
4
ContentValues values = new ContentValues();
values.put("column1", "");
getContentResolver().update(uri, values, "column1 = ? and column2 = ?",
new String[] {"text", "1"});

注意上述代码使用了selection和selectionArgs参数来对想要更新的数据进行约束,以防止所有的行都会受影响。最后,可以调用ContentResolver的delete方法将这条数据删除掉,代码如下所示:

1
getContentResolver().delete(uri, "column2 = ?", new String[] {"1"});

到这里为止,我们就把ContentResolver中的增删改查方法全部学完了。这些知识其实和SQLiteDatabase很类似,需要特别注意的只有uri这个参数而已。那么接下来,我们就利用目前所学的知识,看一看如何读取系统电话簿中的联系人信息。

示例:读取电话簿联系人

新建一个ContactsTest项目。

activity_main.xml

1
2
3
4
5
6
7
8
9
10
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="match_parent">
<ListView
android:id="@+id/contacts_view"
android:layout_width="match_parent"
android:layout_height="match_parent">
</ListView>
</LinearLayout>

MainActivity:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
public class MainActivity extends AppCompatActivity {
ArrayAdapter<String> adpter;
List<String> contactsList = new ArrayList<>();

@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
// 首先获取ListView控件的实例
ListView contactsView = (ListView) findViewById(R.id.contacts_view);
adapter = new ArrayAdapter<String>(this, android.R.layout.simple_list_item_1, contactsList);
// 设置配置器
contactsView.setAdapter(adapter);

if (ContextCompat.checkSelfPermission(this, Manifest.permission.READ_CONTACTS) != PackageManager.PERMISSION_GRANTED) {
ActivityCompat.requestPermissions(this, new String[]{Manifest.permission.READ_CONTACTS}, 1);
} else {
readContacts();
}
}

private void readContacts() {
Cursor cursor = null;
try {
// 查询联系人数据
// ContactsContract.CommonDataKinds.Phone类已经做好了封装,提供了一个CONTENT_URI常量
cursor = getContentResolver().query(ContactsContract.CommonDataKinds.Phone.CONTENT_URI, null, null, null, null);
if(cursor != null) {
while (cursor.moveToNext()) {
// 获取联系人姓名
// DISPLAY_NAME 对应联系人名字这一列
String displayName = cursor.getString(cursor.getColumnIndex(ContactContract.CommonDataKinds.Phone.DISPLAY_NAME));
// 获取联系人手机号
// NUMBER 对应联系人手机号这一列
String number = cursor.getString(cursor.getColumnIndex(ContactsContract.CommonDataKinds.Phone.NUMBER));
// 添加到ListView的数据源List容器中
contactsList.add(displayName + "\n" + number);
}
// 通知刷新一下ListView
adapter.notifyDataSetChanged();
}
} catch (Exception e) {
e.printStackTrace();
} finally {
if (cursor != null) {
// 千万不要忘记关闭Cursor对象
cursor.close();
}
}
}
@Override
public void onRequestPermissionResult(int requestCode, String[] permissions, int[] grantResults) {
switch (requestCode) {
case 1:
if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
readContacts();
} else {
Toast.makeText(this, "You denied the permission", Toast.LENGTH_SHORT).show();
}
break;
default:
}
}
}

读取系统联系人的权限千万不要忘记在AndroidManifest.xml中声明

1
2
3
4
5
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.example.contactstest">
<uses-permission android:name="android.permission.READ_CONTACTS" />
...
</manifest>

创建自己的内容提供器

在自己的程序中访问其他应用程序的数据,总体来说思路还是非常简单的,只需要获取到该应用程序的内容URI,然后借助ContentResolver进行CRUD操作就可以了。可是那些提供外部访问接口的应用程序都是如何实现这种功能的呢?它们又是怎样保证数据的安全性使得隐私数据不会泄漏出去?

创建内容提供器的步骤

前面已经提到过,如果想要实现跨程序共享数据的功能,官方推荐的方式是使用内容提供器,可以通过新建一个类去继承ContentProvider的方式来创建一个自己的内容提供器。ContentProvider类中有6个抽象方法,我们在使用子类继承它的时候,需要将这6个方法全部重写。新建MyProvider继承自ContentProvider,代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public class MyProvider extends ContentProvider {
@Override
public boolean onCreate() {
return false;
}
@Override
public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) {
return null;
}
@Override
public Uri insert(Uri uri, ContentValues values) {
return null;
}
@Override
public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) {
return 0;
}
@Override
public int delete(Uri uri, String selection, String[] selectionArgs) {
return 0;
}
@Override
public String getType(Uri uri) {
return null;
}
}
  1. onCreate()

    1. 初始化内容提供器的时候调用。通常会在这里完成对数据库的创建和升级等操作,返回true表示内容提供器初始化成功,返回false则表示失败。注意,只有当存在ContentResolver尝试访问我们程序中的数据时,内容提供器才会被初始化。
  2. query()

    1. 从内容提供器中查询数据。使用uri参数来确定查询哪张表,projection参数用于确定查询哪些列,selection和selectionArgs参数用于约束查询哪些行,sortOrder参数用于对结果进行排序,查询的结果存放在Cursor对象中返回。
  3. insert()

    1. 向内容提供器中添加一条数据。使用uri参数来确定要添加到的表,待添加的数据保存在values参数中。添加完成后,返回一个用于表示这条新记录的URI。
  4. update()

    1. 更新内容提供器中已有的数据。使用uri参数来确定更新哪一张表中的数据,新数据保存在values参数中,selection和selectionArgs参数用于约束更新哪些行,受影响的行数将作为返回值返回。
  5. delete()

    1. 从内容提供器中删除数据。使用uri参数来确定删除哪一张表中的数据,selection和selectionArgs 参数用于约束删除哪些行,被删除的行数将作为返回值返回。
  6. getType()

    1. 根据传入的内容URI来返回相应的MIME类型。

可以看到,几乎每一个方法都会带有Uri这个参数,这个参数也正是调用ContentResolver的增删改查方法时传递过来的。而现在,我们需要对传入的Uri参数进行解析,从中分析出调用方期望访问的表和数据。回顾一下,一个标准的内容URI写法是这样的:content://com.example.app.provider/table1。这就表示调用方期望访问的是com.example.app这个应用的table1表中的数据。除此之外,我们还可以在这个内容URI的后面加上一个id,如下所示:content://com.example.app.provider/tablel/1。这就表示调用方期望访问的是com.example.app这个应用的table1表中id为1的数据。内容URI的格式主要就只有以上两种,以路径结尾就表示期望访问该表中所有的数据,以id结尾就表示期望访问该表中拥有相应id的数据。我们可以使用通配符的方式来分别匹配这两种格式的内容URI,规则如下:

  • *,表示匹配任意长度的任意字符;
  • #,表示匹配任意长度的数字。

所以,一个能够匹配任意表的内容URI格式就可以写成:content://com.example.app.provider/*,而一个能够匹配table1表中任意一行数据的内容URI格式就可以写成:content://com.example.app.provider/table1/#

接着,我们再借助UriMatcher这个类就可以轻松地实现匹配内容URI的功能。UriMatcher中提供了一个addURI方法,这个方法接收3个参数,可以分别把authority、path和一个自定义代号传进去。这样,当调用UriMatcher的match方法时,就可以将一个Uri对象传入,返回值是某个能够匹配这个Uri对象所对应的自定义代号,利用这个代号,就可以判断出调用方期望访问的是哪张表中的数据了。修改MyProvider中的代码,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
public class MyProvider extends ContenProvider {
public static final int TABLE1_DIR = 0;
public static final int TABLE1_ITEM = 1;
public static final int TABLE2_DIR = 2;
public static final int TABLE2_ITEM = 3;
private static UriMatcher uriMatcher;
static {
uriMatcher = new UriMatcher(UriMatcher.NO_MATCH);
uriMatcher.addURI("com.example.app.provider", "table1", TABLE1_DIR);
uriMatcher.addURI("com.example.app.provider ", "table1/#", TABLE1_ITEM);
uriMatcher.addURI("com.example.app.provider ", "table2", TABLE2_DIR);
uriMatcher.addURI("com.example.app.provider ", "table2/#", TABLE2_ITEM);
}
...
@Override
public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) {
switch (uriMatcher.match(uri)) {
case TABLE1_DIR:
break;
case TABLE1_ITEM:
break;
case TABLE2_DIR:
break;
case TABLE2_ITEM:
break;
default:
break;
}
}
...
}

可以看到,MyProvider中新增了4个整型常量,其中TABLE1_DIR表示访问table1表中的所有数据,TABLE1_ITEM表示访问table1表中的单条数据,TABLE2_DIR表示访问table2表中的所有数据,TABLE2_ITEM表示访问table2表中的单条数据。接着在静态代码块里我们创建了UriMatcher的实例,并调用addURI方法,将期望匹配的内容URI格式传递进去,注意这里传入的路径参数是可以使用通配符的。然后当query方法被调用的时候,就会通过UriMatcher的match方法对传入的Uri对象进行匹配,如果发现UriMatcher中某个内容URI格式成功匹配了该Uri对象,则会返回相应的自定义代号,然后我们就可以判断出调用方期望访问的到底是什么数据了。

上述代码只是以query方法为例做了个示范,其实insert、update、delete这几个方法的实现也是差不多的,它们都会携带Uri这个参数,然后同样利用UriMatcher的match方法判断出调用方期望访向的是哪张表,再对该表中的数据进行相应的操作就可以了。

除此之外,还有一个方法会比较陌生,即getType方法。它是所有的内容提供器都必须提供的一个方法,用于获取Uri对象所对应的MIME类型。一个内容URI所对应的MIME字符串主要由3部分组成,Android对这3个部分做了如下格式规定:

  1. 必须以vnd开头。
  2. 如果内容URI以路径结尾,则后接android.cursor.dir/,如果内容URI以id结尾,则后接 android.cursor.item/
  3. 最后接上vnd.<authority>.<path>

所以,对于content://com.example.app.provider/table1这个内容URI,它所对应的MIME类型就可以写成:vnd.android.cursor.dir/vnd.com.example.app.provider.table1。对于content://com.example.app.provider/table1/1这个内容URI,它所对应的MIME类型就可以写成:vnd.android.cursor.item/vnd.com.example.app.provider.table1

现在可以继续完善MyProvider中的内容了,这次来实现getType()方法中的逻辑,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class MyProvider extends ContentProvider {
...
@Override
public String getType(Uri uri) {
switch (uriMatcher.match(uri)) {
case TABLE1_DIR:
return "vnd.android.cursor.dir/vnd.com.example.app.provider.table1";
case TABLE1_ITEM:
return "vnd.android.cursor.item/vnd.com.example.app.provider.table1";
case TABLE2_DIR:
return "vnd.android.cursor.dir/vnd.com.example.app.provider.table2";
case TABLE2_ITEM:
return "vnd.android.cursor.item/vnd.com.example.app.provider.table2";
default:
break;
}
return null;
}
}

到这里,一个完整的内容提供器就创建完成了,现在任何一个应用程序都可以使用ContentResolver来访问我们程序中的数据。那么前面所提到的,如何才能保证隐私数据不会泄漏出去呢?其实多亏了内容提供器的良好机制,这个问题在不知不觉中已经被解决了。因为所有的CRUD操作都一定要匹配到相应的内容URI格式才能进行,而我们当然不可能向UriMatcher中添加隐私数据的URI,所以这部分数据根本无法被外部程序访问到,安全问题也就不存在了。下面就来实战一下,真正体验一回跨程序数据共享的功能。

实现跨程序数据共享

简单起见,还是在DatabaseTest项目的基础上继续开发,通过内容提供器来给它加入外部访问接口。打开DatabaseTest项目,首先将MyDatabaseHelper中使用Toast弹出创建数据库成功的提示去除掉,因为跨程序访问时不能直接使用Toast。接下来,创建一个内容提供器,右击com.example.databasetest包,new,other,Content Provider。可以看到,这里我们将内容提供器命名为DatabaseProvider.authority指定为com.example.databasetest.providerExported属性表示是否允许外部程序访问我们的内容提供器,Enabled属性表示是否启用这个内容提供器。将两个属性都勾中,点击Finish完成创建。

接着修改DatabaseProvider中的代码,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
public class DatabaseProvider extends ContentProvider {
public static final int BOOK_DIR = 0;
public static final int BOOK_ITEM = 1;
public static final int CATEGORY_DIR = 2;
public static final int CATEGORY_ITEM = 3;
public static final String AUTHORITY = "com.example.sqlitetest.provider";
private static UriMatcher uriMatcher;
private MyDatabaseHelper dbHelper;
static {
uriMatcher = new UriMatcher(UriMatcher.NO_MATCH);
uriMatcher.addURI(AUTHORITY, "book", BOOK_DIR);
uriMatcher.addURI(AUTHORITY, "book/#", BOOK_ITEM);
uriMatcher.addURI(AUTHORITY, "category", CATEGORY_DIR);
uriMatcher.addURI(AUTHORITY, "category/#", CATEGORY_ITEM);
}
@Override
public boolean onCreate() {
dbHelper = new MyDatabaseHelper(getContext(), "Bookstore.db", null, 2);
return true;
}
@Override
public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) {
// 查询数据
SQLiteDatabase db = dbHelper.getReadableDatabase();
Cursor cursor = null;
switch (uriMatcher.match(uri)) {
case BOOK_DIR:
cursor = db.query("Book", projection, selection, selectionArgs, null, null, sortOrder);
break;
case BOOK_ITEM:
String bookId = uri.getPathSegments().get(1);
cursor = db.query("Book", projection, "id = ?", new String[] {bookId}, null, null, sortOrder);
break;
case CATEGORY_DIR:
cursor = db.query("Category", projection, selection, selectionArgs, null, null, sortOrder);
break;
case CATEGORY_ITEM:
String categoryId = uri.getPathSegments().get(1);
cursor = db.query("Category", projection, "id = ?", new String[] {categoryId}, null, null, sortOrder);
break;
default:
break;
}
return cursor;
}
@Override
public Uri insert(Uri uri, ContentValues values) {
// 添加数据
SQLiteDatabase db = dbHelper.getWritableDatabase();
Uri uriReturn = null;
switch (uriMatcher.match(uri)) {
case BOOK_DIR:
case BOOK_ITEM:
long newBookId = db.insert("Book", null, values);
uriReturn = Uri.parse("content://" + AUTHORITY + "/book/" + newBookId);
break;
case CATEGORY_DIR:
case CATEGORY_ITEM:
long newCategoryId = db.insert("Category", null, values);
uriReturn = Uri.parse("content://" + AUTHORITY + "/category/" + newCategoryId);
break;
default:
break;
}
return uriReturn;
}
@Override
public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) {
// 更新数据
SQLiteDatabase db = dbHelper.getWritableDatabase();
int updateRows = 0;
switch (uriMatcher.match(uri)) {
case BOOK_DIR:
updateRows = db.update("Book", values, selection, selectionArgs);
break;
case BOOK_ITEM:
String bookId = uri.getPathSegments().get(1);
updateRows = db.update("Book", values, "id = ?", new String[]{bookId});
break;
case CATEGORY_DIR:
updateRows = db.update("Category", values, selection, selectionArgs);
break;
case CATEGORY_ITEM:
String categoryId = uri.getPathSegments().get(1);
updateRows = db.update("Category", values, "id = ?", new String[]{categoryId});
break;
default:
break;
}
return updateRows;
}
@Override
public int delete(Uri uri, String selection, String[] selectionArgs) {
// 删除数据
SQLiteDatabase db = dbHelper.getWritableDatabase();
int deleteRows = 0;
switch (uriMatcher.match(uri)) {
case BOOK_DIR:
deleteRows = db.delete("Book", selection, selectionArgs);
break;
case BOOK_ITEM:
String bookId = uri.getPathSegments().get(1);
deleteRows = db.delete("Book", "id = ?", new String[]{bookId});
break;
case CATEGORY_DIR:
deleteRows = db.delete("Category", selection, selectionArgs);
break;
case CATEGORY_ITEM:
String categoryId = uri.getPathSegments().get(1);
deleteRows = db.delete("Category", "id = ?", new String[]{categoryId});
break;
default:
break;
}
return deleteRows;
}
@Override
public String getType(Uri uri) {
switch (uriMatcher.match(uri)) {
case BOOK_DIR:
return "vnd.android.cursor.dir/vnd.com.example.sqlitetest.provider.book";
case BOOK_ITEM:
return "vnd.android.cursor.item/vnd.com.example.sqlitetest.provider.book";
case CATEGORY_DIR:
return "vnd.android.cursor.dir/vnd.com.example.sqlitetest.provider.category";
case CATEGORY_ITEM:
return "vnd.android.cursor.item/vnd.com.example.sqlitetest.provider.category";
}
return null;
}
}

代码虽然很长,但是这些内容都非常容易理解,因为使用到的全部都是上面的知识。首先在类的一开始,同样是定义了4个常量,分别用于表示访问Book表中的所有数据、访问Book表中的单条数据、访问Category表中的所有数据和访问Category表中的单条数据。然后在静态代码块里对UriMatcher进行了初始化操作,将期望匹配的几种URI格式添加了进去。

接下来就是每个抽象方法的具体实现了,先来看下onCreate方法,这个方法的代码很短,就是创建了一个MyDatabaseHelper的实例,然后返回true表示内容提供器初始化成功,这时数据库就已经完成了创建或升级操作。

接着看一下query方法,在这个方法中先获取到了SQLiteDatabase的实例,然后根据传入的Uri参数判断出用户想要访问哪张表,再调用SQLiteDatabase的query进行查询,并将Cursor对象返回就好了。注意当访问单条数据的时候有一个细节,这里调用了Uri对象的getPathSegments方法,它会将内容URI权限之后的部分以"/"符号进行分割,并把分割后的结果放入到一个字符串列表中,那这个列表的第0个位置存放的就是路径,第1个位置存放的就是id了。得到了id之后,再通过selection和selectionArgs参数进行约束,就实现了查询单条数据的功能。

再往后就是 insert方法,同样它也是先获取到了SQLiteDatabase的实例、然后根据传入的Uri参数判断出用户想要往哪张表里添加数据。再调用SQLiteDatabase的insert方法进行添加就可以了。注意insert方法要求返回一个能够表示这条新增数据的URI,所以我们还需要调用Uri.parse方法来将一个内容URI解析成Uri对象,当然这个内容URI是以新增数据的id结尾的。

接下来就是update方法了,也是先获取SQLiteDatabase的实例。然后根据传入的Uri参数判断出用户想要更新哪张表里的数据,再调用SQLiteDatabase的update方法进行更新就好了,受影响的行数将作为返回值返回。

下面是delete方法,仍然是先获取到SQLiteDatabase的实例,然后根据传入的Uri参数判断出用户想要删除哪张表里的数据,再调用SQLiteDatabase的delete方法进行删除就好了,被删除的行数将作为返回值返回。

最后是getType方法。这样我们就将内容提供器中的代码全部编写完了。另外还有一点需要注意,内容提供器一定要在AndroidManifest.xml文件中注册才可以使用。不过幸运的是,由于我们是使用Android Studio的快捷方式创建的内容提供器,因此注册这一步已经被自动完成了。打开AndroidManifest.xml文件瞧一瞧,代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.example.sqlitetest">

<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.SQLiteTest">
<provider
android:name=".DatabaseProvider"
android:authorities="com.example.sqlitetest.provider"
android:enabled="true"
android:exported="true"></provider>

<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />

<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>

</manifest>

可以看到,<application>标签内出现了一个新的标签<provider>,我们使用它来对DatabaseProvider这个内容提供器进行注册。android:name属性指定了DatabaseProvider的类名,android:authorities属性指定了DatabaseProvider的authority,而enabled和exported属性则是根据我们刚才勾选的状态自动生成的,这里表示允许DatabaseProvider被其他应用程序进行访问。

现在DatabaseTest这个项目就已经拥有了跨程序共享数据的功能了,我们赶快来尝试一下。首先需要将DatabaseTest程序删除掉,以防止遗留数据对我们造成干扰。然后运行一下项目,将DatabaseTest程序重新安装在模拟器上了。接着关闭掉DatabaseTest这个项目并创建一个新项目ProviderTest我们就将通过这个程序去访问DatabaseTest中的数据。

先来编写一下布局文件,修改activity_main.xml中的代码,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="match_parent" >
<Button
android:id="@+id/add_data"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Add To Book">
</Button>
<Button
android:id="@+id/update_data"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Update Book">
</Button>
<Button
android:id="@+id/delete_data"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Delete From Book">
</Button>
<Button
android:id="@+id/query_data"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Query From Book">
</Button>
</LinearLayout>

布局文件很简单、里面放置了4个按钮,分别用于添加、查询、修改和删除数据。然后修改MainActivity中的代码,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
public class MainActivity extends AppCompatActivity {
private String newId;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);

Button addData = (Button) findViewById(R.id.add_data);
addData.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
Uri uri = Uri.parse("content://com.example.sqlitetest.provider/book");
ContentValues values = new ContentValues();
values.put("name", "A Clash of Kings");
values.put("author", "George Martin");
values.put("pages", 1040);
values.put("price", 22.85);
Uri newUri = getContentResolver().insert(uri, values);
newId = newUri.getPathSegments().get(1);
}
});

Button updateData = (Button) findViewById(R.id.update_data);
updateData.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
Uri uri = Uri.parse("content://com.example.sqlitetest.provider/book");
ContentValues values = new ContentValues();
values.put("name", "A Storm of Swords");
values.put("pages", 1216);
values.put("price", 24.05);
getContentResolver().update(uri, values, null, null);
}
});

Button deleteData = (Button) findViewById(R.id.delete_data);
deleteData.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
Uri uri = Uri.parse("content://com.example.sqlitetest.provider/book");
getContentResolver().delete(uri, null, null);
}
});

Button queryData = (Button) findViewById(R.id.query_data);
queryData.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
Uri uri = Uri.parse("content://com.example.sqlitetest.provider/book");
Cursor cursor = getContentResolver().query(uri, null, null, null, null, null);
if(cursor != null) {
while(cursor.moveToNext()) {
String name = cursor.getString(cursor.getColumnIndex("name"));
String author = cursor.getString(cursor.getColumnIndex("author"));
int pages = cursor.getInt(cursor.getColumnIndex("pages"));
double price = cursor.getDouble(cursor.getColumnIndex("price"));
Log.d("MainActivity", "book name is " + name);
Log.d("MainActivity", "book author is " + author);
Log.d("MainActivity", "book pages is " + pages);
Log.d("MainActivity", "book price is " + price);
}
cursor.close();
}
}
});
}
}

可以看到,我们分别在这4个按钮的点击事件里面处理了增删改查的逻辑。添加数据的时候,首先调用了Uri.parse方法将一个内容URI解析成Uri对象,然后把要添加的数据都存放到ContentValues对象中,接着调用ContentResolver的insert方法执行添加操作就可以了。注意insert方法会返回一个Uri对象,这个对象中包含了新增数据的id,我们通过getPathSegments方法将这个id取出,稍后会用到它。

查询数据的时候,同样是调用了Uri.parse方法将一个内容URI解析成Uri对象,然后调用ContentResolver的query方法去查询数据,查询的结果当然还是存放在Cursor对象中的。之后对Cursor进行遍历,从中取出查询结果,并打印出来。

更新数据的时候,也是先将内容URI解析成Uri对象,然后把想要更新的数据存放到ContentValues对象中,再调用ContentResolver的update方法执行更新操作就可以了。

注意这里我们为了不想让Book表中的其他行受到影响,在调用Uri.parse方法时,给内容URI的尾部增加了一个id,而这个id正是添加数据时所返回的。这就表示我们只希望更新刚刚添加的那条数据,Book表中的其他行都不会受影响。

删除数据的时候,也是使用同样的方法解析了一个以id结尾的内容URI,然后调用ContentResolver的delete方法执行删除操作就可以了。由于我们在内容URI里指定了一个id,因此只会删掉拥有相应id的那行数据,Book表中的其他数据都不会受影响。

Android_数据和文件存储

数据和文件存储概览

Android使用的文件系统类似于其他平台上基于磁盘的文件系统,提供了以下几种保存应用数据的选项:

  • 应用专属存储空间:存储仅供应用使用的文件,可以存储到内部存储卷中的专属目录或外部存储空间中的其他专属目录。使用内部存储空间中的目录保存其他应用不应访问的敏感信息。
  • 共享存储:存储与其他应用共享的文件,包括媒体、文档和其他文件。
  • 偏好设置:以键值对形式存储私有原始数据。
  • 数据库:使用Room持久性库将结构化数据存储在专用数据库中。

下表汇总了这些选项的特点:

内容类型 访问方法 所需权限 其他应用是否可访问 卸载应用时是否移除文件
应用专属文件 仅供应用使用的文件 从内部存储空间访问,可以使用getFilesDir()getCacheDir()方法;从外部存储空间访问,可以使用getExternalFilesDir()getExternalCacheDir()方法 从内部存储空间访问不需要任何权限。如果应用在搭载Android 4.4(API级别19)或更高版本的设备上运行,从外部存储空间访问不需要任何权限
媒体 可共享的媒体文件(图片、音频文件、视频) MediaStore API 在Android 11(API级别30)或更高版本中,访问其他应用的文件需要READ_EXTERNAL_STORAGE在Android 10(API级别29)中,访问其他应用的文件需要READ_EXTERNAL_STORAGEWRITE_EXTERNAL_STORAGE在Android 9(API级别28)或更低版本中,访问所有文件均需要相关权限 是,但其他应用需要READ_EXTERNAL_STORAGE权限
文档和其他文件 其他类型的可共享内容,包括已下载的文件 存储访问框架 是,可以通过系统文件选择器访问
应用偏好设置 键值对 Jetpack Preferences库
数据库 结构化数据 Room持久性库

应根据自己的具体需求选择解决方案:

  • 数据需要占用多少空间?

    内部存储空间中用于存储应用专属数据的空间有限。如果需要保存大量数据,应使用其他类型的存储空间。

  • 数据访问需要达到怎样的可靠程度?

    如果应用的基本功能需要某些数据(例如应用启动时需要的数据),可以将相应数据存放到内部存储目录或数据库中。存储在外部存储空间中的应用专属文件并非一直可以访问,因为有些设备允许用户移除外部存储实体设备。

  • 需要存储哪类数据?

    如果数据仅供您的应用使用,应使用应用专属存储空间。对于可分享的媒体内容,应使用共享的存储空间,以便其他应用可以访问相应内容。对于结构化数据,应使用偏好设置(适合键值对数据)或数据库(适合包含2个以上列的数据)。

  • 数据是否应仅供你的应用使用?

    在存储敏感数据(不可通过任何其他应用访问的数据)时,应使用内部存储空间、偏好设置或数据库。内部存储空间的一个额外优势是用户无法看到相应数据。

存储位置的类别

Android提供两类物理存储位置:内部存储空间和外部存储空间。在大多数设备上,内部存储空间小于外部存储空间。不过,所有设备上的内部存储空间都是始终可用的,因此在存储应用所依赖的数据时更为可靠。

可移除卷(例如SD卡)在文件系统中属于外部存储空间。Android使用路径(例如/sdcard)表示这些存储设备。

注意:可用于保存文件的确切位置可能因设备而异。因此,请勿使用硬编码的文件路径。

默认情况下,应用本身存储在内部存储空间中。不过,如果APK非常大,也可以在应用的清单文件中指明偏好设置,以便将应用安装到外部存储空间:

1
2
3
4
<manifest ...
android:installLocation="preferExternal" >
...
</manifest>

对外部存储空间的访问和所需权限

Android定义了以下与存储相关的权限:READ_EXTERNAL_STORAGEWRITE_EXTERNAL_STORAGEMANAGE_EXTERNAL_STORAGE

在较低版本的Android系统中,应用需要声明READ_EXTERNAL_STORAGE权限才能访问位于外部存储空间中应用专属目录之外的任何文件。此外,应用需要声明WRITE_EXTERNAL_STORAGE权限才能向应用专属目录以外的任何文件写入数据。

Android系统的版本越新,就越依赖于文件的用途而不是位置来确定应用对特定文件的访问和写入能力。特别是,如果应用以Android 11(API级别30)或更高版本为目标平台,WRITE_EXTERNAL_STORAGE权限完全不会影响应用对存储的访问权限。这种基于用途的存储模型可增强用户隐私保护,因为应用只能访问其在设备文件系统中实际使用的区域。

Android 11引入了MANAGE_EXTERNAL_STORAGE权限,该权限提供对应用专属目录和MediaStore之外文件的写入权限。此权限使大多数应用无需声明此权限即可实现其用例,参阅有关如何管理存储设备上所有文件的指南。

分区存储

为了让用户更好地管理自己的文件并减少混乱,以Android 10(API级别29)及更高版本为目标平台的应用在默认情况下被授予了对外部存储空间的分区访问权限(即分区存储)。此类应用只能访问外部存储空间上的应用专属目录,以及本应用所创建的特定类型的媒体文件。

注意:如果应用在运行时请求与存储空间相关的权限,面向用户的对话框会表明应用正在请求对外部存储广泛的访问,即使启用分区存储也是如此。

除非应用需要访问存储在应用专属目录和MediaStore API可以访问的目录之外的文件,否则请使用分区存储。如果将应用专属文件存储在外部存储空间中,则可以将这些文件存放在外部存储空间中的应用专属目录内,以便更加轻松地采用分区存储。这样,在启用分区存储后,应用将可以继续访问这些文件。

如需应用适合分区存储,参阅存储用例和最佳实践指南。如应用有其他用例未包含在分区存储范围内,请提交功能请求。可以暂时选择停用分区存储

文件存储

文件存储不对存储的内容进行任何格式化处理,所有数据原封不动地保存到文件当中,因而比较适合存储一些简单的文本数据或二进制数据。如果想使用文件存储的方式来保存一些较为复杂的结构化数据,需要定义一套自己的格式规范,方便之后将数据从文件中重新解析出来。

写入到文件中

Context类中提供了一个openFileOutput()方法,可以用于将数据存储到指定的文件中。此方法接受两个参数:第一个参数是文件名,在文件创建的时候使用,注意此文件名不可包含路径,因为所有的文件都默认存储到/data/data/<package name>/files/目录下;第二个参数是文件的操作模式,主要有MODE_PRIVATEMODE_APPEND两种模式可选。默认是MODE_PRIVATE,表示当指定相同文件名时,直接覆盖原文件内容。MODE_APPEND则表示追加内容。MODE_WORLD_READABLEMODE_WORLD_WRITEABLE表示允许其他应用程序对程序的文件进行读写操作,由于这两种模式过于危险,容易引起应用的安全漏洞,已在Android 4.2版本中废弃。

openFileOutput()方法返回一个FileOutputStream对象,得到这个对象之后借助它构建出OutputStreamWriter对象,再使用OutputStreamWriter对象构建出BufferedWriter对象,如此就可以使用通过BufferedWriter将文本内容写入文件。这就是用Java流的方式将数据写入文件中。

java代码示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public void save() {
String somedata = "Data to save";
FileOutputStream out = null;
BufferedWriter writer = null;
try {
out = openFileOutput("data", Context.MODE_PRIVATE);
writer = new BufferedWriter(new OutputStreamWriter(out));
writer.write(somedata);
} catch (IOException e) {
e.printStackTrace();
} finally {
try {
if (writer != null) {
writer.close();
}
} catch (IOException e) {
e.printStackTrace();
}
}
}

kotlin代码示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
fun save(inputText: String) {
// kotlin没有异常检查机制,意味着使用kotlin编写的所有代码不会强制要求你进行异常捕获或异常抛出;
// 此处的try catch代码块是参照Java的编程规范添加的,即使不写依然可以编译通过
try {
val output = openFileOutput("data", Context.MODE_PRIVATE)
val writer = BufferedWriter(OutputStreamWriter(output))
// kotlin提供的一个内置扩展函数,保证在lambda表达式中的代码全部执行完之后自动将外层的流关闭,
// 这样就不用再编写一个finally语句手动去关闭流了。
writer.use {
it.write(inputText)
} catch (e: IOException) {
e.printStackTrace();
}
}
}

实例

创建一个FilePersistenceTest项目,修改activity_main.xml中的代码,如下:

1
2
3
4
5
6
7
8
9
10
11
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="match_parent" >
<EditText
android:id="@+id/editText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="Type something here" >
</EditText>
</LinearLayout>

这里是在布局中加入了一个EditText,用于输入文本内容。

此时运行程序,街面上有一个文本输入框,输入内容后,按下Back键,输入的内容就丢失了,因为此时它是一个瞬时数据,在Activity销毁后会被回收。现在要做的是,在数据被回收之前将它存储到文件中。修改MainActivity代码:

java版:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
public class MainActivity extends AppCompatActivity() {
private EditText edit;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
edit = (EditText) findViewById(R.id.edit);
}
@Override
protected void onDestroy() {
super.onDestory();
String inputText = edit.getText().toString();
save(inputText);
}
public void save(String inputText) {
FileOutputStream out = null;
BufferedWriter writer = null;
try {
out = openFileOutput("data", Context.MODE_PRIVATE);
writer = new BufferedWriter(new OutputStreamWriter(out));
writer.write(inputText);
} catch (IOException e) {
e.printStackTrace();
} finally {
try {
if (writer != null) {
writer.close();
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
}

kotlin版:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState : Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
}
override fun onDestroy() {
super.onDestroy()
val inputText = editText.text.toString()
save(inputText)
}
private fun save(inputText: String) {
try {
val output = openFileOutput("data", Context.MODE_PRIVATE)
val writer = BufferedWriter(OutputStreamWriter(output))
writer.use {
it.write(inputText)
}
} catch (e: IOException) {
e.printStackTrace()
}
}
}

进入/data/data/com.example.filetest/files/目录,即可以看到有一个data文件。

需要想办法在下次启动程序时让这些数据能够还原到EditText中。

从文件中读取数据

类似于将数据存储到文件中,Context类中还提供了一个openFileInput方法,用于从文件中读取数据,只接收一个参数,即要读取的文件名,然后系统会自动到/data/data/<package name>/files/目录下去加载这个文件,并返回一个FileInputStream对象,得到了这个对象之后再通过Java流的方式就可以将数据读取出来了。

Java版:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public String load() {
FileInputStream in = null;
BufferedReader reader = null;
StringBuilder content = new StringBuilder();
try {
in = openFileInput("data");
reader = new BufferedReader(new InputStreamReader(in));
String line = "";
while((line = reader.readLine()) != null) {
content.append(line);
}
} catch (IOException e) {
e.printStackTrace();
} finally {
if (reader != null) {
try {
reader.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
return content.toString();
}

kotlin版:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
fun load(): String {
val content = StringBuilder()
try {
val input = openFileInput("data")
val reader = BufferedReader(InputStreamReader(input))
reader.use {
// 此处使用了一个forEachLine函数,是Kotlin提供的一个内置扩展函数,它会将读到的每行内容都回调到lambda表达式中,在lambda表达式中完成拼接逻辑即可
reader.forEachLine {
content.append(it)
}
}
} catch (e: IOException) {
e.printStackTrace()
}
return content.toString()
}

在这段代码中,首先通过openFileInput方法获取到了一个FileInputStream对象,然后借助它又构建出了一个InputStreamReader对象,接着再使用InputStreamReader构建出一个BufferedReader 对象,这样我们就可以通过BufferedReader进行一行行地读取,把文件中所有的文本内容全部读取出来,并存放在一个StringBuilder对象中,最后返回StringBuilder的toString就可以了。

修改MainActivity中的代码,使得重新启动程序时EditText中能够保留我们上次输入的内容。如下所示:

Java版

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
public class MainActivity extends AppCompatActivity {
private EditText edit;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
edit = (EditText) findViewById(R.id.edit);
String inputText = load();
if (!TextUtils.isEmpty(inputText)) {
edit.setText(inputText);
edit.setSelection(inputText.length());
Toast.makeText(this, "Restoring succeeded", Toast.LENGTH_SHORT).show();
}
}
...
public String load() {
FileInputStream in = null;
BufferedReader reader = null;
StringBuilder content = new StringBuilder();
try {
in = openFileInput("data");
reader = new BufferedReader(new InputStreamReader(in));
String line = "";
while((line = reader.readLine()) != null) {
content.append(line);
}
} catch (IOException e) {
e.printStackTrace();
} finally {
if (reader != null) {
try {
reader.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
return content.toString();
}
}

kotlin版:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
val inputText = load()
if(inputText.isNotEmpty()) {
editText.setText(inputText)
editText.setSelection(inputText.length)
Toast.makeText(this, "Restoring succeeded", Toast.LENGTH_SHORT).show()
}
}
private fun load(): String {
val content = StringBuilder()
try {
val input = openFileInput("data")
val reader = BufferedReader(InputStreamReader(input))
reader.use {
reader.forEachLine {
content.append(it)
}
}
} catch (e: IOException) {
e.printStackTrace()
}
return content.toString()
}
...
}

可以看到,在onCreate方法中调用load方法来读取文件中存储的文本内容,如果读到的内容不为null,就调用EditText的setText方法将内容填充到EditText里,并调用setSelection方法将输入光标移动到文本的末尾位置以便于继续输入,然后弹出一句还原成功的提示。

注意,上述代码在对字符串进行非空判断的时候使用了TextUtils.isEmpty方法,这是一个非常好用的方法,它可以一次性进行两种空值的判断。当传入的字符串等于null或者等于空字符串的时候,这个方法都会返回true,从而使得我们不需要先单独判断这两种空值再使用逻辑运算符连接起来了。

现在重新运行一下程序,刚才保存的Content字符串肯定会被填充到EditText中,然后编写一点其他的内容,比如在EditText中输入Hello,接着按下Back键退出程序,再重新启动程序,这时刚才输入的内容并不会丢失,而是还原到了EditText中。

总结

文件存储的核心技术就是Context类中提供的openFileInput和openFileOutput方法,之后就是利用Java的各种流来进行读写操作。

但是文件存储的方式并不适合用于保存一些较为复杂的文本数据。

SQLite数据库存储

Android系统内置了SQLite数据库。SQLite是一款轻量级的关系型数据库,运算速度快,占用资源少,通常只需要几百KB的内存就够了,因而特别适合在移动设备上使用。

SQLite不仅支持标准的SQL语法,还遵循了数据库的ACID事务。

前面的文件存储只适用于保存一些简单的数据和键值对,当需要存储大量复杂的关系型数据的时候就需要用到数据库。比如短信程序中可能会有很多个会话,每个会话中又包含了很多条信息内容,并且大部分会话还可能各自对应了电话簿中的某个联系人。

创建数据库

Android为了让我们能够更加方便地管理数据库,专门提供了一个SQLiteOpenHelper帮助类,借助这个类就可以非常简单地对数据库进行创建和升级。

SQLiteOpenHelper是一个抽象类,这意味着要创建一个自己的帮助类继承它。SQLiteOpenHelper中有两个抽象方法,分别是onCreate和onUpgrade,必须重写这两个方法,然后分别在这两个方法中去实现创建、升级数据库的逻辑。

SQLiteOpenHelper中还有两个非常重要的实例方法:getReadableDatabase和getWritableDatabase。这两个方法都可以创建或打开一个现有的数据库(如果数据库已存在则直接打开,否则创建一个新的数据库)并返回一个可对数据库进行读写操作的对象。不同的是,当数据库不可写入的时候(如磁盘空间已满),getReadableDatabase方法返回的对象将以只读的方式去打开数据库,而getWritableDatabase方法则将出现异常。

SQLiteOpenHelper中有两个构造方法可供重写,一般使用参数少的那个构造方法即可。这个构造方法中接收4个参数,第一个参数是Context,必须要有它才能对数据库进行操作。第二个参数是数据库名,创建数据库时使用的就是这里指定的名称。第三个参数允许我们在查询数据的时候返回一个自定义的 Cursor,一般都是传入null。第四个参数表示当前数据库的版本号,可用于对数据库进行升级操作。构建出SQLiteOpenHelper的实例之后,再调用它的getReadableDatabase或getWritableDatabase方法就能够创建数据库了,数据库文件会存放在/data/data/<package name>/databases/目录下。此时,重写的onCreate方法也会得到执行,所以通常会在这里去处理一些创建表的逻辑。

示例:我们希望创建一个名为BookStore.db的数据库,然后在这个数据库中新建一张Book表,表中有id(主键)、作者、价格、页数和书名等列。Book表建表语句:

1
2
3
4
5
6
create table Book (
id integer primary key autoincrement,
author text,
price real,
pages integer,
name text)

SQLite的数据类型很简单,integer表示整型,real表示浮点型,text表示文本类型,blob表示二进制类型。另外,上述建表语句中还使用了primary keyid列设为主键,并用autoincrement关键字表示id列是自增长的。然后需要在代码中去执行这条SQL语句,才能完成创建表的操作。新建MyDatabaseHelper类继承SQLiteOpenHelper,代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class MyDatabaseHelper extends SQLiteOpenHelper {
public static final String CREATE_BOOK = "create table Book ("
+ "id integer primary key autoincrement, "
+ "author text, "
+ "price real, "
+ "pages integer, "
+ "name text)";
private Context mContext;
public MyDatabaseHelper(Context context, String name,
SQLiteDatabase.CursorFactory factory, int version) {
super(context, name, factory, version);
mContext = context;
}
@Override
public void onCreate(SQLiteDatabase db) {
db.execSQL(CREATE_BOOK);
Toast.makeText(mContext, "Create succeeded", Toast.LENGTH_SHORT).show();
}
@Override
public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {

}
}

建表语句定义成了一个字符串常量,然后在onCreate方法中又调用了SQLiteDatabase的execSQL方法去执行这条建表语句,并弹出一个Toast提示创建成功,这样就可以保证在数据库创建完成的同时还能成功创建Book表。

修改activity_main.xml中的代码:

1
2
3
4
5
6
7
8
9
10
11
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="match_parent" >
<Button
android:id="@+id/create_database"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Create database">
</Button>
</LinearLayout>

布局文件很简单,就是加入了一个按钮,用于创建数据库最后修改MainActivity中的代码,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class MainActivity extends AppCompatActivity {
private MyDatabaseHelper dbHelper;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
dbHelper = new MyDatabaseHelper(this, "BookStore.db", null, 1);
Button createDatabase = (Button) findViewById(R.id.create_database);
createDatabase.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
dbHelper.getWritableDatabase();
}
});
}
}

在onCreate方法中构建了一个MyDatabaseHelper对象,并且通过构造函数的参数将数据库名指定为 “BookStore.db”,版本号指定为1,然后在按钮的点击事件里调用了getWritableDatabase方法。这样当第一次点击按钮时,就会检测到当前程序中并没有BookStore.db数据库,于是会创建该数据库并调用MyDatabaseHelper中的onCreate方法,这样Book表也就得到了创建,然后会弹出一个Toast提示创建成功。再次点击按钮时,会发现此时已经存在BookStore.db数据库了,因此不会再创建一次。

怎样才能证实数据库的确创建成功了?如果还是使用File Explorer,那么最多只能看到databases目录下出现了一个BookStore.db文件,Book表是无法通过File Explorer看到的。因此换一种查看方式:使用adb shell来对数据库和表的创建情况进行检查。

adb是Android SDK中自带的一个调试工具,使用这个工具可以直接对连接在电脑上的手机或模拟器进行调试操作。它存放在sdk的platform-tools目录下,如果想要在命令行中使用这个工具,就需要先把它的路径配置到环境变量里。

如果使用的是Windows系统,在系统变量里找到Path并点击编辑,将platform-tools目录配置进去;如果使用的是Linux或Mac系统,可以在home路径下编辑.bash文件,将platform-tools目录配置进去即可。

打开命令行界面,输人adb shell,就进入到设备的控制台,输入su命令切换成超级管理员,接下来使用cd命令进入到/data/data/com.example.databasetest/databases/目录下,并使用ls命令查看到该目录里的文件。

image-20220808112149277

这个目录下出现了两个数据库文件,一个正是我们创建的BookStore.db而另一个BookStore.db-journal则是为了让数据库能够支持事务而产生的临时日志文件,通常情况下这个文件的大小都是0字节。

image-20220808112213683

接下来借助sqlite命令打开数据库,只需要键入sqlite3,后面加上数据库名即可。

首先来看一下目前数据库中有哪些表,键入.table命令,可以看到此时数据库中有两张表,android_metadata表是每个数据库中都会自动生成的,而另外一张Book表就是我们在MyDatabaseHelper中创建的。这里还可以通过.schema命令来查看它们的建表语句。

由此证明,BookStore.db数据库和Book表确实已经创建成功了。之后键入.exit.quit命令可以退出数据库的编辑,再键入exit命令就可以退出设备控制台了。

image-20220808113854180

升级数据库

onUpgrade方法是用于对数据库进行升级的。

目前项目中已经有一张Book表用于存放书的各种详细数据,如果我们想再添加一张Category表用于记录图书的分类,该怎么做呢?比如Category表中有id(主键)、分类名和分类代码这几个列,那么建表语句就可以写成:

1
2
3
4
create table Category (
id integer primary key autoincrement,
category_name text,
category_code integer)

接下来我们将这条建表语句添加到MyDatabaseHelper中,代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public class MyDatabaseHelper extends SQLiteOpenHelper {
public static String CREATE_BOOK = "create table Book ("
+ "id integer primary key autoincrement, "
+ "author text, "
+ "price real, "
+ "pages integer, "
+ "name text)";
public static final String CREATE_CATEGORY = "create table Category ("
+ "id integer primary key autoincrement, "
+ "category_name text, "
+ "category_code integer)"; //+
private Context mContext;
public MyDatabaseHelper(Context context, String name,
SQLiteDatabase.CursorFactory factory, int version) {
super(Context, name, factory, version);
mContext = context;
}
@Override
public void onCreate(SQLiteDatabase db) {
db.execSQL(CREATE_BOOK);
db.execSQL(CREATE_CATEGORY); //+
Toast.makeText(mContext, "Create succeeded", Toast.LENGTH_SHORT).show();
}
@Override
public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {

}
}

可见,此程序企图通过MyDatabaseHelper构造时自动调用onCreate来创建Category数据库,但是现在我们重新运行一下程序并点击按钮,没有弹出创建成功的提示。当然,可以通过adb工具到数据库中再去检查一下,这样会更加地确认Category表没有创建成功。因为按钮绑定的点击事件回调是dbHelper.getWritableDatabase();,而此时BookStore.db数据库已经存在了,之后不管我们怎样点击按钮,getWritableDatabase()都不会使MyDatabaseHelper中的onCreate方法再次执行,因此新添加的表也就无法得到创建了。可以看出,getWritableDatabase()只能用于创建一次数据库,之后只是单纯地打开现有的数据库并返回一个可对数据库进行读写操作的对象。

正确的做法是运用SQLiteOpenHelper的升级功能,修改MyDatabaseHelper类中的代码:

1
2
3
4
5
6
7
8
9
public class MyDatabaseHelper extends SQLiteOpenHelper {
...
@Override
public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
db.execSQL("drop table if exists Book");
db.execSQL("drop table if exists Category");
onCreate(db);
}
}

可以看到,我们在onUpgrade方法中执行了两条DROP语句,如果发现数据库中已经存在Book表或Category表了,就将这两张表删除掉。然后再调用onCreate方法重新创建。这里先将已经存在的表删除掉。接下来的问题就是如何让onUpgrade方法能够执行了,还记得SQLiteOpenHelper的构造方法里接收的第四个参数表示当前数据库的版本号,之前我们传人的是1,现在只要传入一个比1大的数,就可以让 onUpgrade方法得到执行了。修改MainActivity中的代码,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class MainActivity extends AppCompatActivity {
private MyDatabaseHelper dbHelper;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
dbHelper = new MyDatabaseHelper(this, "BookStore.db", null, 2);
Button createDatabase = (Button) findViewById(R.id.create_database);
createDatabase.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
dbHelper.getWritableDatabase();
}
});
}
}

这里将数据库版本号指定为2,表示我们对数据库进行升级了。现在重新运行程序,并点击按钮,这时就会再次弹出创建成功的提示。为了验证Category表已经创建成功,在adb shell中打开BookStore.db数据库,然后键入.table命令,接着键入.schema命令查看一下建表语句。由此可以看出,Category表已经创建成功了,同时也说明我们的升级功能的确起到了作用。

image-20220808144646963

添加数据

如果你比较熟悉SQL语言的话,一定会知道添加数据时使用insert,查询数据时使用select,更新数据时使用update,删除数据时使用delete。但是开发者的水平总会是参差不齐的,未必每一个人都能非常熟悉地使用SQL语言,因此Android也提供了一系列的辅助性方法,使得在Android中即使不去编写SQL语句,也能轻松完成所有的CRUD操作。

前面我们已经知道,调用SQLiteOpenHelper的getReadableDatabase或getwritableDatabase方法是可以用于创建和升级数据库的,不仅如此,这两个方法还都会返回一个SQLiteDatabase对象,借助这个对象就可以对数据进行CRUD操作了。

首先向数据库的表添加数据。SQLiteDatabase中提供了一个insert方法:这个方法就是专门用于添加数据的。它接收3个参数,第一个参数是表名;第二个参数用于在未指定添加数据的情况下给某些可为空的列自动赋值NULL,一般我们用不到这个功能,直接传入null即可。第三个参数是一个ContentValues对象,它提供了一系列的put方法重载,用于向ContentValues中添加数据,只需要将表中的每个列名以及相应的待添加数据传入即可。这就是基本用法,接下来通过例子测试。修改activity_main.xml中的代码,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="match_parent" >

...

<Button
android:id="@+id/add_data"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Add data">
</Button>
</LinearLayout>

在布局文件中又新增了一个按钮,稍后就会在这个按钮的点击事件里编写添加数据的逻辑。接着修改MainActivity中的代码,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
public class MainActivity extends AppCompatActivity {
private MyDatabaseHelper dbHelper;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
dbHelper = new MyDatabaseHelper(this, "BookStore.db", null, 2);
...
Button addData = (Button) findViewById(R.id.add_data);
addData.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
SQLiteDatabase db = dbHelper.getWritableDatabase();
ContentValues values = new ContentValues();
// 开始组装第一条数据
values.put("name", "The Da Vinci Code");
values.put("author", "Dan Brown");
values.put("pages", 454);
values.put("price", 16.96);
db.insert("Book", null, values); // 插入第一条数据
values.clear();
// 组装第二条
values.put("name", "The Lost Symbol");
values.put("author", "Dan Brown");
values.put("pages", 510);
values.put("price", 19.95);
db.insert("Book", null, values); // 插入第一条数据
values.clear();
}
});
}
}

这里只对Book表里其中四列的数据进行了组装,id那一列没并没给它赋值。这是因为在前面创建表的时候,我们就将id列设置为自增长了,它的值会在入库的时候自动生成。接下来使用ContentValues分别组装了两次不同的内容,并调用了两次insert方法。

image-20220808151332330

更新数据

接下来看看怎样修改表中已有的数据。SQLiteDatabase中也提供了一个非常好用的update方法,用于对数据进行更新,这个方法接收4个参数,第一个参数和insert方法一样是表名。第二个参数是ContentValues对象,要把更新数据在这里组装进去。第三、第四个参数用于约束更新某一行或某几行中的数据,不指定的话默认就是更新所有行。

比如说修改第一本书的价格。应该怎么操作呢?首先修改activity_main.xml中的代码,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="match_parent" >
...
<Button
android:id="@+id/update_data"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Update data">
</Button>
</LinearLayout>

添加了一个用于更新数据的按钮。然后修改MainActivity中的代码,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class MainActivity extends AppCompatActivity {
private MyDatabaseHelper dbHelper;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
dbHelper = new MyDatabaseHelper(this, "BookStore.db", null, 2);
...
Button updateData = (Button) findViewById(R.id.update_data);
updateData.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
SQLiteDatabase db = dbHelper.getWritableDatabase();
ContentValues values = new ContentValues();
values.put("price", 10.99);
db.update("Book", values, "name = ?", new String[]{"The Da Vinci Code"});
}
});
}
}

这里在更新数据按钮的点击事件里面构建了一个ContentValues对象,并且只给它指定了一组数据,说明只是想把价格这一列的数据更新成10.99。然后调用了SQLiteDatabase的update方法去执行具体的更新操作,可以看到,这里使用了第三、第四个参数来指定具体更新哪几行。第三个参数对应的是SQL语句的where部分,表示更新所有name等于?的行,而?是一个占位符,可以通过第四个参数提供的一个字符串数组为第三个参数中的每个占位符指定相应的内容。因此上述代码想表达的意图是将名字是The Da Vinci Code的这本书的价格改成10.99。

image-20220808152812798

删除数据

SQLiteDatabase中提供了一个delete方法,专门用于删除数据,这个方法接收3个参数。第一个参数仍然是表名,这个已经没什么好说的了,第二、第三个参数又是用于约束删除某一行或某几行的数据,不指定的话默认就是删除所有行。修改activity_main.xml中的代码,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="match_parent" >
...
<Button
android:id="@+id/delete_data"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Delete data">
</Button>
</LinearLayout>

修改MainActivity代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class MainActivity extends AppCompatActivity {
private MyDatabaseHelper dbHelper;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
dbHelper = new MyDatabaseHelper(this, "BookStore.db", null, 2);
...
Button deleteData = (Button) findViewById(R.id.delete_data);
deleteData.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
SQLiteDatabase db = dbHelper.getWritableDatabase();
db.delete("Book", "pages > ?", new String[]{"500"});
}
});
}
}

可以看到,我们在删除按钮的点击事件里指明去删除Book表中的数据,并且通过第二、第三个参数来指定仅删除那些页数超过500页的书。当然这个需求很奇怪,这里也仅仅是为了做个测试。你可以先查看一下当前Book表里的数据,其中The Lost Symbol这本书的页数超过了500页,也就是说当我们点击删除按钮时,这条记录应该会被删除掉。

image-20220808153356471

查询数据

此处只介绍Android上的查询功能。

SQLiteDatabase中还提供了一个query方法用于对数据进行查询。这个方法的参数非常复杂、最短的一个方法重载也需要传人7个参数。第一个参数还是表名;第二个参数用于指定去查询哪几列,如果不指定则默认查询所有列;第三、第四个参数用于约束查询某一行或某几行的数据,不指定则默认查询所有行的数据;第五个参数用于指定需要去group by的列,不指定则表示不对查询结果进行group by操作;第六个参数用于对group by之后的数据进行进一步的过滤,不指定则表示不进行过滤;第七个参数用于指定查询结果的排序方式,不指定则表示使用默认的排序方式。更多详细的内容可以参考下表。其他几个query方法的重载其实也大同小异,这里就不再进行介绍了。

query方法参数 对应SQL部分 描述
table from table_name 指定表名
columns select column1, column2 指定列名
selection where column = value 指定where约束条件
selectionArgs - 为where条件中的占位符提供具体值
groupBy group by column 指定需要group by的列
having having column = value 对group by后的结果进一步约束
orderBy order by column1, column2 指定查询结果的排序方式

虽然query方法的参数非常多,但是不要对它产生畏惧,因为我们不必为每条查询语句都指定所有的参数,多数情况下只需要传入少数几个参数就可以完成查询操作了。调用query方法后会返回一个Cursor对象,查询到的所有数据都将从这个对象中取出。

修改activity_main.xml中的代码,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="match_parent" >
...
<Button
android:id="@+id/query_data"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Query data">
</Button>
</LinearLayout>

修改MainActivity中的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
public class MainActivity extends AppCompatActivity {
private MyDatabaseHelper dbHelper;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
dbHelper = new MyDatabaseHelper(this, "BookStore.db", null, 2);
...
Button queryData = (Button) findViewById(R.id.query_data);
queryData.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
SQLiteDatabase db = dbHelper.getWritableDatabase();
Cursor cursor = db.query("Book", null, null, null, null, null, null);
if(cursor.moveToFirst()) {
do {
// 遍历Cursor对象,取出数据并打印
String name = cursor.getString(cursor.getColumnIndex("name"));
String author = cursor.getString(cursor.getColumnIndex("author"));
int pages = cursor.getInt(cursor.getColumnIndex("pages"));
double price = cursor.getDouble(cursor.getColumnIndex("price"));
Log.d("MainActivity", "book name is " + name);
Log.d("MainActivity", "book author is " + author);
Log.d("MainActivity", "book pages is " + pages);
Log.d("MainActivity", "book price is " + price);
} while (cursor.moveToNext());
}
cursor.close();
}
});
}
}

我们首先在查询按钮的点击事件里面调用了SQLiteDatabase的query方法去查询数据。这里的query方法非常简单,只是使用了第一个参数指明去查询Book表,后面的参数全部为null。这就表示希望查询这张表中的所有数据,虽然这张表中目前只剩下一条数据了。查询完之后就得到了一个Cursor对象,接着我们调用它的moveToFirst方法将数据的指针移动到第一行的位置,然后进入了一个循环当中,去遍历查询到的每一行数据。在这个循环中可以通过Cursor的getColumnIndex方法获取到某一列在表中对应的位置索引,然后将这个索引传入到相应的取值方法中,就可以得到从数据库中读取到的数据了。接着我们使用Log的方式将取出的数据打印出来,借此来检查一下读取工作有没有成功完成。最后别忘了调用close方法来关闭Cursor。

点击一下Querydata按钮后,查看logcat的打印内容。

image-20220808160134432

使用SQL操作数据库

虽然Android已经给我们提供了很多非常方便的API用于操作数据库,不过有一些人不习惯去使用这些辅助性的方法,而是更加青睐于直接使用SQL来操作数据库。这种人一般都属于SQL大牛,Android充分考虑到了这些人的编程习惯,同样提供了一系列的方法,使得可以直接通过SQL来操作数据库。下面就来简略使用SQL来完成前面的CRUD操作。

  • 添加数据的方法如下:

    1
    2
    3
    4
    db.execSQL("insert into Book (name, author, pages, price) values(?, ?, ?, ?)",
    new String[]{"The Da Vinci Code", "Dan Brown", "454", "16.96"});
    db.execSQL("insert into Book (name, author, pages, price) values(?, ?, ?, ?)",
    new String[]{"The Lost Symbol", "Dan Brown", "510", "19.95"});
  • 更新数据的方法如下:

    1
    2
    db.execSQL("update Book set price = ? where name = ?",
    new String[]{"10.99", "The Da Vinci Code"});
  • 删除数据的方法如下:

    1
    db.execSQL("delete from Book where pages > ?", new String[]{"500"});
  • 查询数据的方法如下:

    1
    db.rawQuery("select * from Book", null);

可以看到,除了查询数据的时候调用的是SQLiteDatabase的rawQuery方法,其他的操作都是调用的execSQL方法。选择使用哪一种方式就看你个人的喜好了。