持久化技术简介
数据持久化就是指将那些内存中的瞬时数据保存到存储设备中,保证即使在手机或电脑关机的情况下,这些数据仍然不会丢失。保存在内存中的数据是处于瞬时状态的,而保存在存储设备中的数据是处于持久状态的,持久化技术则提供了一种机制可以让数据在瞬时状态和持久状态之间进行转换。
持久化技术被广泛应用于各种程序设计的领域当中,而本书中要探讨的自然是 Android 中的数据持久化技术。Android 系统中主要提供了 3 种方式用于简单地实现数据持久化功能,即文件存储、SharedPreferences 存储以及数据库存储。
文件存储
概念
文件存储是 Android 中最基本的一种数据存储方式,它不对存储的内容进行任何的格式化处理,所有数据都是原封不动地保存到文件当中的,因而它比较适合用于存储一些简单的文本数据或二进制数据。如果你想使用文件存储的方式来保存一些较为复杂的文本数据,就需要定义一套自己的格式规范,这样可以方便之后将数据从文件中重新解析出来。
将数据存储到文件中
Context
类中提供了一个 openFileOutput()
方法,可以用于将数据存储到指定的文件中。这个方法接收两个参数,第一个参数是文件名,在文件创建的时候使用的就是这个名称,注意这里指定的文件名不可以包含路径,因为所有的文件都是默认存储到/data/data/
MODE_PRIVATE
:默认操作模式,表示当指定同样文件名的时候,所写入的内容将会覆盖原文件中的内容。MODE_APPEND
:表示如果该文件已存在,就往文件里面追加内容,不存在就创建新文件。MODE WORLD READABLE
和MODE WORLD WRITEABLE
:这两种模式表示允许其他的应用程序对我们程序中的文件进行读写操作,不过由于这两种模式过于危险,很容易引起应用的安全性漏洞,已在 Android4.2 版本中被废弃。
openFileOutput ()
方法返回的是一个 FileOutputStream
对象,得到了这个对象之后就可以使用 Java 流的方式将数据写入到文件中了。
1 | String data = "Data to save"; |
从文件中读取数据
类似于将数据存储到文件中,Context
类中还提供了一个 openFileInput()
方法,用于从文件中读取数据。这个方法要比 openFileOutput()
简单一些,它只接收一个参数,即要读取的文件名,然后系统会自动到/data/data/FileInputStream
对象,得到了这个对象之后再通过 Java 流的方式就可以将数据读取出来了。
1 | FileInputStream in = null; |
首先通过 openFileInput()
方法获取到了一个 FileInputStream
对象,然后借助它又构建出了一个 InputStreamReader
对象,接着再使用 InputStreamReader
构建出一个 BufferedReader
对象,这样我们就可以通过 BufferedReader
进行一行行地读取,把文件中所有的文本内容全部读取出来,并存放在一个 StringBuilder
对象中,最后将读取到的内容返回就可以了。
案例
使得重新启动程序时 EditText 中能够保留我们上次输入的内容。
1 | public class MainActivity extends AppCompatActivity { |
loadData()
方法主要是文件流读取的基本操作,然后重写了 onCreate()
方法,这样就可以保证在活动创建时一定会调用 loadData()
方法。在 onCreate()
方法中调用了 TextUtils.isEmpty()
方法来对读取到的字符串进行非空判断。TextUtils.isEmpty()
方法可以一次性进行两种空值的判断。当传入的字符串等于 null 或者等于空字符串的时候,这个方法都会返回 true,从而使得我们不需要先单独判断这两种空值再使用逻辑运算符连接起来了。
SharedPreferences 存储
不同于文件的存储方式,SharedPreferences 是使用键值对的方式来存储数据的。也就是说,当保存一条数据的时候,需要给这条数据提供一个对应的键,这样在读取数据的时候就可以通过这个键把相应的值取出来。而且 SharedPreferences 还支持多种不同的数据类型存储。
将数据存储到 SharedPreferences 中
要想使用 SharedPreferences 来存储数据,首先需要获取到 SharedPreferences 对象。Android 中主要提供了 3 种方法用于得到 SharedPreferences 对象。
Context
类中的 getSharedPreferences()
方法
此方法接收两个参数:
- 第一个参数用于指定 SharedPreferences 文件的名称,如果指定的文件不存在则会创建一个,SharedPreferences 文件都是存放在
/data/data/<package name>/shared prefs/
目录下的。 - 第二个参数用于指定操作模式,目前只有
MODE PRIVATE
这一种模式可选,它是默认的操作模式,和直接传入 0 效果是相同的,表示只有当前的应用程序才可以对这个 SharedPreferences 文件进行读写。其他几种操作模式均已被废弃。
Activity
类中的 getPreferences()
方法
这个方法和 Context 中的 getSharedPreferences()
方法很相似,不过它只接收一个操作模式参数,因为使用这个方法时会自动将当前活动的类名作为 SharedPreferences 的文件名。
PreferenceManager
类中的 getDefaultSharedPreferences()
方法
这是一个静态方法,它接收一个 Context 参数,并自动使用当前应用程序的包名作为前缀来命名 SharedPreferences 文件。
得到了 SharedPreferences 对象之后,就可以开始向 SharedPreferences 文件中存储数据了,主要可以分为 3 步实现。
- 调用 SharedPreferences 对象的
edit()
方法来获取一个 SharedPreferences.Editor 对象。 - 使用
putXXX()
方法向 SharedPreferences.Editor 对象中添加数据,比如添加字符串使用putString()
方法。 - 调用
apply()
方法将添加的数据提交,从而完成数据存储操作。
案例
新建一个 SharedPreferencesTest 项目,然后修改 activity_main.xml 中的代码,如下所示:
1 | <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" |
MainActivity 中的代码
1 | public class MainActivity extends AppCompatActivity { |
首先给按钮注册了一个点击事件,然后在点击事件中通过 getSharedPreferences()
方法指定 SharedPreferences 的文件名为 data,并得到了 SharedPreferences.Editor
对象。接着向这个对象中添加了 3 条不同类型的数据,最后调用 apply()
方法进行提交,从而完成了数据存储的操作。
从 SharedPreferences 中读取数据
SharedPreferences
对象中提供了一系列的 get
方法,用于对存储的数据进行读取,每种 get
方法都对应了 SharedPreferences.Editor
中的一种 put
方法,比如读取一个布尔型数据就使用 getBoolean()
方法,读取一个字符串就使用 getString()
方法。这些 get
方法都接收两个参数,第一个参数是键,传入存储数据时使用的键就可以得到相应的值了;第二个参数是默认值,即表示当传入的键找不到对应的值时会以什么样的默认值进行返回。
与存储数据不同,读取数据不需要获取 SharedPreferences.Editor 对象,只需要 SharedPreferences 对象即可,步骤如下:
- 获取 SharedPreferences 对象
- 通过 get 方法获取存储的数据
activity_main.xml 中加还原数据按钮的代码
1 | <Button |
MainActivity 的代码:
1 | public class MainActivity extends AppCompatActivity { |
SQLite 数据库存储
论谁的数据存储的功能强大,那我只能说还得是数据库更厉害,前两者与之比较简直是相形见绌!Android 系统内置了 SQLite 数据库,它是一款轻量级的关系型数据库,运算速度非常快,占用资源很少,通常只需要几百 KB 的内存就足够了,因而特别适合在移动设备上使用。
创建数据库
Android 提供了 SQLiteOpenHelper 帮助类,它让我们能够更加方便地管理数据库,借助这个类就可以非常简单地对数据库进行创建和升级。
我们需要创建一个类来继承 SQLiteOpenHelper 帮助类,它有两个抽象方法,分别是 onCreate()
和 onUpgrade()
,我们必须在自己的类里面重写这两个方法,然后分别在这两个方法中去实现创建、升级数据库的逻辑。
SQLiteOpenHelper 中还有两个非常重要的实例方法:getReadableDatabase()
和 getWritableDatabase()
。这两个方法都可以创建或打开一个现有的数据库(如果数据库已存在则直接打开,否则创建一个新的数据库),并返回一个可对数据库进行读写操作的对象。不同的是,当数据库不可写人的时候(如磁盘空间已满),getReadableDatabase()
方法返回的对象将以只读的方式去打开数据库,而 getWritableDatabase()
方法则将出现异常。
SQLiteOpenHelper 中有两个构造方法可供重写,一般使用参数少一点的那个构造方法即可。这个构造方法中接收 4 个参数:
- 第一个参数是 Context,必须要有它才能对数据库进行操作。
- 第二个参数是数据库名,创建数据库时使用的就是这里指定的名称。
- 第三个参数允许我们在查询数据的时候返回一个自定义的 Cursor,一般都是传人 nulL。
- 第四个参数表示当前数据库的版本号,可用于对数据库进行升级操作。
构建出 SQLiteOpenHelper 的实例之后,再调用它的 getReadableDatabase()
或 getWritableDatabase()
方法就能够创建数据库了,数据库文件会存放在 /data/data/<package name>/databases/
目录下。此时,重写的 onCreate()
方法也会得到执行,所以通常会在这里去处理一些创建表的逻辑。
新建一个 DatabaseTest 项目。
这里我们希望创建一个名为 BookStore.db 的数据库,然后在这个数据库中新建一张 Book 表,表中有 id(主键)、作者、价格、页数和书名等列。创建数据库表当然还是需要用建表语句的,这里也是要考验一下你的 SQL 基本功了,Book 表的建表语句如下所示:
1 | create table Book ( |
SQLite 不像其他的数据库拥有众多繁杂的数据类型,它的数据类型很简单,integer
表示整型,real
表示浮点型,text
表示文本类型,blob
表示二进制类型。另外,上述建表语句中我们还使用了 primary key
将 id
列设为主键,并用 autoincrement
关键字表示 id
列是自增长的。
然后需要在代码中去执行这条 SQL 语句,才能完成创建表的操作。新建 MyDatabaseHelper
类继承自 SQLiteOpenHelper,代码如下所示:
1 | public class MyDatabaseHelper extends SQLiteOpenHelper { |
activity_main.xml 中的代码添加按钮组件,最后修改 MainActivity 中的代码
1 | public class MainActivity extends AppCompatActivity { |
这里我们在 onCreate()
方法中构建了一个 MyDatabaseHelper
对象,并且通过构造函数的参数将数据库名指定为 BookStore.db,版本号指定为 1,然后在 Create database 按钮的点击事件里调用了 getWritableDatabase()
方法。这样当第一次点击 Create database 按钮时,就会检测到当前程序中并没有 BookStore.db 这个数据库,于是会创建该数据库并调用 MyDatabaseHelper 中的 onCreate()
方法,这样 Book 表也就得到了创建,然后会弹出一个 Toast 提示创建成功。再次点击 Create database 按钮时,会发现此时已经存在 BookStore.db 数据库了,因此不会再创建一次。
尝试使用 Device File Explorer 进行查看,发现只能看到 databases 目录下出现了一个
BookStore.db 文件,Book 表是无法通过 File Explorer 看到的。因此我们需要换另一种方式查看,使用 adb shell 来对数据库和表的创建情况进行检查。相关指令如下:
1 | //打开数据库 |
升级数据库
onUpgrade()
方法是用于对数据库进行升级的,重写 onUpgrade()
方法来实现添加新表
1 | public class MyDatabaseHelper extends SQLiteOpenHelper { |
在 onUpgrade()
方法中执行了两条 DROP
语句,如果发现数据库中已经存在 Book 表或 Category 表了,就将这两张表删除掉,然后再调用 onCreate()
方法重新创建。这里先将已经存在的表删除掉,因为如果在创建表时发现这张表已经存在了,就会直接报错。
接下来的问题就是如何让 onUpgrade()
方法能够执行了,还记得 SQLiteOpenHelper 的构造方法里接收的第四个参数吗?它表示当前数据库的版本号,之前我们传入的是 1,现在只要传入一个比 1 大的数,就可以让 onUpgrade()
方法得到执行了。修改 MainActivity 中的代码,如下所示:
1 | public class MainActivity extends AppCompatActivity { |
现在重新运行程序,并点击创建按钮,这时就会再次弹出创建成功的提示。当然,我们也可以通过 adb shell 来验证。
添加数据
在数据库中,我们对数据进行的操作无非就是增删改查这四种。相比于在学习数据库时不断编写 insert、delete、update、select 语句,Android 提供了一系列的辅助性方法,使得在 Android 中即使不去编写 SQL 语句,也能轻松完成所有的增删改查操作。
在前面有一点我们没有注意到,就是 SQLiteOpenHelper 的 getReadableDatabase()
和 getwritableDatabase()
方法都会返回一个 SQLiteDatabase 对象,借助这个对象就可以对数据进行增删改查操作了。
那我们先来学习一下如何添加数据。SQLiteDatabase 中提供了一个 insert()
方法,这个方法就是专门用于添加数据的。它接收 3 个参数:
- 第一个参数是表名。
- 第二个参数用于在未指定添加数据的情况下给某些可为空的列自动赋值 NULL, 一般我们用不到这个功能,直接传入 null 即可。
- 第三个参数是一个 ContentValues 对象,它提供了一系列的 put()方法重载,用于向 ContentValues 中添加数据,只需要将表中的每个列名以及相应的待添加数据传入即可。
MainActivity 中的代码如下所示:
1 | public class MainActivity extends AppCompatActivity { |
将上面的操作转成 SQL 语句就是:
1 | insert into Book(name, author, pages, price) values("The Da Vinci Code", "Dan Brown", 454, 16.53); |
我们先获取到了 SQLiteDatabase
对象,然后使用 ContentValues
来对要添加的数据进行组装。由于 id 被指定为自增长,所以不需要设置 id 的值。然后我们可以打开 BookStore 数据库,执行 select * from Book;
语句(不要忘记最后添加分号)来验证是否添加成功。
更新数据
更新数据使用 update()
方法,该方法接收 4 个参数:
- 第一个参数是表名。
- 第三个参数是一个 ContentValues 对象,把要更新的数据在这里组装进去。
- 第三、第四个参数用于约束更新某一行或某几行中的数据,不指定的话默认更新所有行。
MainActivity 中的代码:
1 | public class MainActivity extends AppCompatActivity { |
从这里可以看出 SQLiteDatabase 的 update()
的第三、第四个参数的具体作用。第三个参数对应的是 SQL 语句的 where 部分,表示更新有 name 等于?的行,而?是一个占位符,可以通过第四个参数提供的一个字符串数组为第三个参数中的每个占位符指定相应的内容
删除数据
更新数据使用 delete()
方法,该方法接收 3 个参数:
- 第一个参数是表名。
- 第二、三个参数用于约束删除某一行或某几行中的数据,不指定的话默认删除所有行
1 | public class MainActivity extends AppCompatActivity { |
查询数据
查询语句使用 query()
方法,因为它的复杂性,所以参数有很多。最短的一个方法也需要传入 7 个参数,分别如下:
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 |
指定查询结果的排序方式 |
MainActivity 中的代码,如下所示:
1 | public class MainActivity extends AppCompatActivity { |
moveToFirst()
方法将数据的指针移动到第一行的位置,然后进入了一个循环当中,去遍历查询到的每一行数据。在这个循环中可以
通过 Cursor 的 getColumnIndex()
方法获取到某一列在表中对应的位置索引,然后将这个索引传入到相应的取值方法中,就可以得到从数据库中读取到的数据了。
使用 SQL 操作数据库
如果我们不习惯使用 Android 提供的方法来对数据进行操作时,我们也可以直接执行 SQL 语句来实现对数据的操作。
添加数据
1
2
3db.execSQL("insert into Book (name,author,pages,price) values(?,?,?,?)", new String[]{"The Da Vinci Code","Dan Brown","454","16.96"})
SQL跟新数据
1
2
3db.execSQL("update Book set price = ? where name = ?", new string[]{"10.99", "The Da Vinci Code"});
SQL删除数据
1
2
3db.execSQL("delete from Book where pages > ?", new String[]{"500"});
SQL查询数据
1
2
3
4db.rawQuery("select * from Book",null);
rawQuery("select * from Book where author = ?", new String[]{"Dan Brown"})
SQL
简而言之,第一个参数是通配 SQL 语句,第二个参数是填充通配符的值。
使用 LitePal 操作数据库
LitePal 是一款开源的 Android 数据库框架,它采用了对象关系映射(ORM)的模式,并将我们平时开发最常用到的一些数据库功能进行了封装,使得不用编写一行 SQL 语句就可以完成各种建表和増删改查的操作。LitePal 的项目主页上也有详细的使用文档,地址是:https://github.com/LitePalFramework/LitePal 。
配置 LitePal
那么怎样才能在项目中使用开源库呢?过去的方式比较复杂,通常需要下载开源库的 Jar 包或者源码,然后再集成到我们的项目当中。而现在就简单得多了,大多数的开源项目都会将版本提交到 jcenter 上,我们只需要在 app/build.gradle
文件中声明该开源库的引用就可以了。
因此,要使用 LitePal 的第一步,就是编辑 app/build.gradle
文件,在 dependencies 闭包中添加如下内容:
1 | dependencies { |
添加的这一行声明中,前面部分是固定的,最后的 3.2.3 是版本号的意思,最新的版本号可以到 LitePal 的项目主页上去查看。
在 repositories
块中添加以下代码
1 | jcenter() |
再次 build 就把 LitePal 成功引入到当前项目中了,接下来需要配置 litepal.xml 文件。在 app/src/main
目录下创建一个 assets 目录,然后在 assets 目录下再新建一个 litepal.xml
文件(选择 File,命名的时候加文件后缀),接着编辑 litepal.xml
文件中的内容,如下所示:
1 |
|
其中,<dbname>
标签用于指定数据库名,<version>
标签用于指定数据库版本号,<List>
标签用于指定所有的映射模型,我们稍后就会用到。
最后在 AndroidManifest.xml
文件中添加:
1 | <manifest> |
创建和升级数据库
刚才在介绍的时候已经说过,LitePal 采取的是对象关系映射(ORM)的模式,那么什么是对象关系映射呢?简单点说,我们使用的编程语言是面向对象语言,而使用的数据库则是关系型数据库,那么将面向对象的语言和面向关系的数据库之间建立一种映射关系,这就是对象关系映射了。
之前为了创建一张 Book 表,需要先分析表中应该包含哪些列,然后再编写出一条建表语句,最后在自定义的 SQLiteOpenHelper 中去执行这条建表语句。但是使用 LitePal 就可以用面向对象的思维来实现同样的功能了,定义一个 Book 类,代码如下所示:
1 | public class Book extends LitePalSupport { |
这是一个典型的 Java bean,在 Book 类中我们定义了 id、author、price、pages、name 这几个字段,并生成了相应的 getter 和 setter 方法。学过 java web 的应该知道,Book 类对应的是数据库中的 Book 表,而类中的每一个字段分别对应了表中的每一个列,这就是对象关系映射最直观的体验。
接下来我们还需要将 Book 类添加到映射模型列表当中,修改 litepal.xml
中的代码,如下所示:
1 |
|
这里使用 <mapping/>
标签来声明我们要配置的映射模型类,注意一定要使用完整的类名。不管有多少模型类需要映射,都使用同样的方式配置在 <list>
标签下即可。
到这里我们就已经把所有工作都完成了,现在只要进行任意一次数据库的操作,BookStore.db 数据库应该就会自动创建出来。那么我们修改 MainActivity 中的代码,如下所示:
1 | public class MainActivity extends AppCompatActivity { |
Connector.getDatabase()
方法是一次最简单的数据库操作,只要点击一下按钮,数据库就会自动创建完成了。运行一下程序,然后点击 Create database 按钮,通过 adb shell 查看,如下图所示:
升级数据库
删除之前的数据库可能会造成数据丢失的问题。然而在 LitePal 中,我们并不需要担心这个问题,我们只需要添加或者修改目标内容,然后将版本号 +1 就行了。
比如我们想要向 Book 表中添加一个 press(出版社)列,那么直接在 Book 类中添加一个 press 字段即可并设置好 get 和 set 方法即可(如果有带参的构造函数,记得在参数中添加新属性),代码如下所示:
1 | public class Book extends LitePalSupport { |
与此同时,我们再添加一张 Category 表,那么只需要创建一个 Category 类即可,代码如下所示:
1 | public class Category extends LitePalSupport { |
改完了所有我们想改的东西,只需要记得将版本号 +1 就行了。当然由于这里还添加了一个新的模型类,因此也需要将它添加到映射模型列表中。修改 litepal.xml 中的代码,如下所示:
1 |
|
添加数据
对于增删改操作,模型类必须要继承 LitePalSupport 类才行。 之后我们只需要创建出模型类的实例,再将所有要存储的数据设置好,最后调用一下 save()
方法就可以了。
好在我们写的代码已经继承了,所以代码直接复用就行。因此我们只需要修改 MainActivity 代码,如下所示:
1 | public class MainActivity extends AppCompatActivity { |
更新数据
更新数据要比添加数据稍微复杂一点,因为它的 API 接口比较多,这里我们只介绍最常用的几种更新方式。
首先,最简单的一种更新方式就是对数据库中 已存储的对象 重新设值,然后重新调用 save()
方法即可。例子如下所示:
1 | public void ClicktoUpData(View view) { |
其中 LitePal.find(model.class,id)
方法的第一个参数为表对应的类,第二个为要更新的记录的 id。
第二种方法是使用 model.update(id)
或 model.updateAll(conditions)
方法,示例代码如下:
1 | public void ClicktoUpData(View view) { |
其中 update()
方法指定 id 即可,updateAll()
方法可以指定一个或多个约束条件,如果不指定条件语句的话,就表示更新所有数据。
当你想把一个字段的值更新成默认值时,是不可以使用上面的方式来设置数据的。在 Java 中任何一种数据类型的字段都会有默认值,当我们 new 出一个 Book 对象时,其实所有字段都已经被初识化成默认值了,比如 pages 默认为 0,因此,如果我们想把数据库表中的 pages 列更新成 0,直接调用 book.setPages(0)
是不可以的,因为即使不调用这行代码,pages 字段本身也是 0,LitePal 此时是不会对这个列进行更新的。
对于将数据更新成默认值的操作,LitePal 统一提供了一个 setToDefault()
方法,然后传入相应的列名就可以了实现了。
比如我们可以这样写:
1 | Book book = new Book(); |
这段代码的意思是,将所有书的页数都更新为 0,因为 updateAll()
方法中没有指定约束条件,因此更新操作对所有数据都生效了。
删除数据
删除数据有两种方法:LitePal.delete(model.class,id)
方法和 LitePal.deleteAll(model.class,conditions)
方法。示例如下:
1 | public void ClicktoCreateDataBase(View view) { |
查询数据
对于查询数据,LitePal 提供了 find()
、findAll()
、select()
、where()
、order()
、limit()
、offset()
方法进行组合查询,
select()
:用于指定查询哪几列的数据,对应了 SQL 当中的 select 关键字。比如只查 name 和 author 这两列的数据,就可以这样写:
1 | List<Book> books = LitePal.select("name","author").find(Book.class); |
where()
方法:用于指定查询的约束条件,对应了 SQL 当中的 where 关键字。比如只查页数大于 400 的数据,就可以这样写:
1 | List<Book> books = LitePal.where("pages > ?","400").find(Book.class); |
order()
方法:用于指定结果的排序方式,对应了 SQL 当中的 order by 关键字。比如将查询结果按照书价从高到低排序,就可以这样写:
1 | List<Book> books = LitePal.order("price desc").find(Book.class); |
其中 desc
表示降序排列,asc
或者不写表示升序排列。
limit()
方法:用于指定查询结果的数量,比如只查表中的前 3 条数据,就可以这样写:
1 | List<Book> books = LitePal.limit(3).find(Book.class); |
offset()
方法:用于指定查询结果的偏移量,比如查询表中的第 2 条、第 3 条、第 4 条数据,就可以这样写:
1 | List<Book> books = LitePal.limit(3).offset(1).find(Book.class); |
由于 limit(3)
查询到的是前 3 条数据,这里我们再加上 offset(1)
进行一个位置的偏移,就能实现查询第 2 条、第 3 条、第 4 条数据的功能了。limit()
和 offset()
方法共同对应了 SQL 当中的 limit 关键字。