数据持久化就是指将那些内存中的瞬时数据保存到存储设备中,保证即使在手机或计算机关机的情况下,这些数据仍然不会丢失。 保存在内存中的数据是处于瞬时状态的,而保存在存储设备中的数据是处于持久状态的。持久化技术提供了一种机制,可以让数据在瞬时状态和持久状态之间进行转换。
Android
系统中主要提供了3
种方式用于简单地实现数据持久化功能:文件存储、SharedPreferences
存储以及数据库存储。
文件存储是Android
中最基本的数据存储方式,它不对存储的内容进行任何格式化处理,所有数据都是原封不动地保存到文件当中的,因而它比较适合存储一些简单的文本数据或二进制数据。 如果你想使用文件存储的方式来保存一些较为复杂的结构化数据,就需要定义一套自己的格式规范,方便之后将数据从文件中重新解析出来。
Context
类中提供了一个openFileOutput()
方法,可以用于将数据存储到指定的文件中。这个方法接收两个参数:第一个参数是文件名,在文件创建的时候使用,注意这里指定的文件名不可以包含路径,因为所有的文件都默认存储到/data/data/
目录 下;第二个参数是文件的操作模式,主要有MODE_PRIVATE
和MODE_APPEND
两种模式可选,默认是MODE_PRIVATE
,表示当指定相同文件名的时候,所写入的内容将会覆盖原文件中的内容,而MODE_APPEND
则表示如果该文件已存在,就往文件里面追加内容,不存在就创建新文件。
其实文件的操作模式本来还有另外两种:MODE_WORLD_READABLE
和MODE_WORLD_WRITEABLE
。这两种模式表示允许其他应用程序对我们程序中的文件进行读写操作,不过由于这两种模式过于危险,很容易引起应用的安全漏洞,已在Android 4.2
版本中被废弃。
openFileOutput()
方法返回的是一个FileOutputStream
对象,得到这个对象之后就可以使用Java
流的方式将数据写入文件中了。 以下是一段简单的代码示例,展示了如何将一段文本内容保存到文件中:
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()
}
}
这里通过openFileOutput()
方法能够得到一个FileOutputStream
对象,然后借助它构建出一个OutputStreamWriter
对 象,接着再使用OutputStreamWriter
构建出一个BufferedWriter
对象,这样你就可以通过BufferedWriter
将文本内容写入文件中了。
注意,这里还使用了一个use
函数,这是Kotlin
提供的一个内置扩展函数。它会保证在Lambda
表达式中的代码全部执行完之后自动将外层的流关闭,这样就不需要我们再编写一个finally
语句,手动去关闭流了,是一个非常好用的扩展函数。
另外,Kotlin
是没有异常检查机制(checked exception
)的。这意味着使用Kotlin
编写的所 代码都不会强制要求你进行异常捕获或异常抛出。上述代码中的try catch
代码块是参照Java
的编程规范添加的,即使你不写try catch
代码块,在Kotlin
中依然可以编译通过。
下面我们就编写一个完整的例子,借此学习一下如何在Android
项目中使用文件存储的技术。首先创建一个FilePersistenceTest
项目,并修改activity_main.xm
l中的代码,如下所示:
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
tools:context=".MainActivity">
<EditText
android:id="@+id/editText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="Type something here" />
LinearLayout>
这里只是在布局中加入了一个EditText
,用于输入文本内容。
其实现在你就可以运行一下程序了,界面上肯定会有一个文本输入框。然后在文本输入框中随 意输入点什么内容,再按下Bac
k键,这时输入的内容肯定就已经丢失了,因为它只是瞬时数据,在Activity
被销毁后就会被回收。而这里我们要做的,就是在数据被回收之前,将它存储到 文件当中。修改MainActivity
中的代码,如下所示:
class MainActivity : AppCompatActivity() {
private lateinit var inputText: EditText
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
inputText = findViewById(R.id.editText);
}
override fun onDestroy() {
super.onDestroy()
val inputText = inputText.text.toString()
save(inputText)
}
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()
}
}
}
可以看到,首先我们重写了onDestroy()
方法,这样就可以保证在Activity
销毁之前一定会调用这个方法。在onDestroy()
方法中,我们获取了EditText
中输入的内容,并调用save()
方法把输入的内容存储到文件中,文件命名为data
。save()
方法中的代码和之前的示例基本相同,这里就不再做解释了。
现在重新运行一下程序,并在EditText
中输入一些内容,然后按下Bac
k键关闭程序,这时我们输入的内容就保存到文件中了。
那么如何才能证实数据确 实已经保存成功了呢?我们可以借助Device File Explorer
工具查看一下。这个工具在Android Studio
的右侧边栏当中,通常是在右下角的位置,如果你的右侧边栏中没有这个工具的话,也 可以使用快捷键Ctrl + Shift + A(Mac系统是command + shift + A)
打开搜索功能,在搜索框中输入Device File Explorer
即可找到这个工具。
类似于将数据存储到文件中,Context
类中还提供了一个openFileInput()
方法,用于从文件中读取数据。这个方法要比openFileOutput(
)简单一些,它只接收一个参数,即要读取的文件名,然后系统会自动到/data/data/
目录下加载这个文件,并返 回一个FileInputStream
对象,得到这个对象之后,再通过流的方式就可以将数据读取出来了。
以下是一段简单的代码示例,展示了如何从文件中读取文本数据:
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()
}
在这段代码中,首先通过openFileInput()
方法获取了一个FileInputStream
对象,然后借助它又构建出了一个InputStreamReader
对象,接着再使用InputStreamReader
构建出 一个BufferedReader
对象,这样我们就可以通过BufferedReader
将文件中的数据一行行读 取出来,并拼接到StringBuilder
对象当中,最后将读取的内容返回就可以了。
注意,这里从文件中读取数据使用了一个forEachLine
函数,这也是Kotlin
提供的一个内置扩展函数,它会将读到的每行内容都回调到Lambda
表达式中,我们在Lambda
表达式中完成拼接逻辑即可。
了解了从文件中读取数据的方法,那么我们就来继续完善上一小节中的例子,使得重新启动程序时EditText
中能够保留我们上次输入的内容。修改MainActivity
中的代码,如下所示:
class MainActivity : AppCompatActivity() {
private lateinit var inputText: EditText
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
inputText = findViewById(R.id.editText)
val text = load()
if (text.isNotEmpty()) {
inputText.setText(text)
inputText.setSelection(text.length)
Toast.makeText(this, "Restoring succeeded", Toast.LENGTH_SHORT).show()
}
}
override fun onDestroy() {
super.onDestroy()
val inputText = inputText.text.toString()
save(inputText)
}
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()
}
}
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()
方法读取文件中存储的文本内容,如果读到的内容不为空,就调用EditText
的setText()
方法将内容填充到EditTex
t 里,并调用setSelection()
方法将输入光标移动到文本的末尾位置以便继续输入,然后弹出 一句还原成功的提示。
SharedPreferences
存储不同于文件的存储方式,SharedPreferences
是使用键值对的方式来存储数据的。也就是说, 当保存一条数据的时候,需要给这条数据提供一个对应的键,这样在读取数据的时候就可以通过这个键把相应的值取出来。 而且SharedPreferences
还支持多种不同的数据类型存储,如果存储的数据类型是整型,那么读取出来的数据也是整型的;如果存储的数据是一个字符串,那么读取出来的数据仍然是字符串。
使用SharedPreferences
进行数据持久化要比使用文件方便很多。
SharedPreferences
中要想使用SharedPreferences
存储数据,首先需要获取SharedPreferences
对象。Android
中主要提供了以下两种方法用于得到SharedPreferences
对象。
Context
类中的getSharedPreferences()
方法**此方法接收两个参数:第一个参数用于指定SharedPreferences
文件的名称,如果指定的文件不存在则会创建一个,SharedPreferences
文件都是存放在/data/data/
目录下的;第二个参数用于指定操作模式,目前只有默认的 MODE_PRIVATE
这一种模式可选,它和直接传入0
的效果是相同的,表示只有当前的应用程序才可以对这个SharedPreferences
文件进行读写。**其他几种操作模式均已被废弃, MODE_WORLD_READABLE
和MODE_WORLD_WRITEABLE
这两种模式是在Android 4.2
版本中被废弃的,MODE_MULTI_PROCESS
模式是在Android 6.0
版本中被废弃的。
Activity
类中的getPreferences()
方法这个方法和Context
中的getSharedPreferences()
方法很相似,不过它只接收一个操作模式参数,因为使用这个方法时会自动将当前Activity
的类名作为SharedPreferences
的文件名。
得到了SharedPreferences
对象之后,就可以开始向SharedPreferences
文件中存储数据了,主要可以分为3
步实现:
SharedPreferences
对象的edit()
方法获取一个SharedPreferences.Editor
对象;SharedPreferences.Editor
对象中添加数据,比如添加一个布尔型数据就使用putBoolean()
方法,添加一个字符串则使用putString()
方法,以此类推;apply()
方法将添加的数据提交,从而完成数据存储操作;下面通过一个例子来体验一下SharedPreferences
存储的用法吧。新建一个SharedPreferencesTest
项目,然后修改activity_main.xml
中的代码,如下所示:
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
tools:context=".MainActivity">
<Button
android:id="@+id/saveButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Save Data" />
LinearLayout>
然后修改MainActivity
中的代码,如下所示:
class MainActivity : AppCompatActivity() {
private lateinit var saveButton: Button
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
saveButton = findViewById(R.id.saveButton)
saveButton.setOnClickListener {
val editor = getSharedPreferences("data", Context.MODE_PRIVATE).edit()
editor.putString("name", "Tom")
editor.putInt("age", 28)
editor.putBoolean("married", false)
editor.apply()
}
}
}
可以看到,这里首先给按钮注册了一个点击事件,然后在点击事件中通过getSharedPreferences()
方法指定SharedPreferences
的文件名为data
,并得到了SharedPreferences.Editor
对象。接着向这个对象中添加了3
条不同类型的数据,最后调用apply()
方法进行提交,从而完成了数据存储的操作。
运行程序了,进入程序的主界面后,点击一下按钮。这时的数据应该已经保存成功了,不过为了证实一下,我们还是要借助Device File Explorer
来进行查看。打开Device File Explorer
,然后进 入/data/data/com.example.sharedpreferencestest/shared_prefs/
目录下,可以看 到生成了一个data.xml
文件。
SharedPreferences
中读取数据SharedPreferences
对象中提供了一系列的get
方法,用于读取存储的数据,每种get
方法都对应了SharedPreferences.Editor
中的一种put
方法,比如读取一个布尔型数据就使用getBoolean()
方法,读取一个字符串就使用getString()
方法。这些get
方法都接收两个参数:第一个参数是键,传入存储数据时使用的键就可以得到相应的值了;第二个参数是默认值,即表示当传入的键找不到对应的值时会以什么样的默认值进行返回。
在SharedPreferencesTest
项目的基础上继续开发,修改activity_main.xm
l中的代码,如下所示:
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
tools:context=".MainActivity">
<Button
android:id="@+id/saveButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Save Data" />
<Button
android:id="@+id/restoreButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Restore Data" />
LinearLayout>
这里增加了一个还原数据的按钮,我们希望通过点击这个按钮来从SharedPreferences
文件中读取数据。修改MainActivity
中的代码,如下所示:
restoreButton.setOnClickListener {
val prefs = getSharedPreferences("data", Context.MODE_PRIVATE)
val name = prefs.getString("name", "")
val age = prefs.getInt("age", 0)
val married = prefs.getBoolean("married", false)
Log.e("prefs", "name is $name")
Log.e("prefs", "age is $age")
Log.e("prefs", "married is $married")
}
可以看到,我们在还原数据按钮的点击事件中首先通过getSharedPreferences()
方法得到了SharedPreferences
对象,然后分别调用它的getString()
、getInt()
和getBoolean()
方法,去获取前面所存储的姓名、年龄和是否已婚,如果没有找到相应的值, 就会使用方法中传入的默认值来代替,最后通过Log
将这些值打印出来。
登录界面的布局activity_login.xml
中的代 码,如下所示:
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
...
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<CheckBox
android:id="@+id/rememberPass"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
android:text="Remember password"
android:textSize="18sp" />
LinearLayout>
<Button
android:id="@+id/login"
android:layout_width="200dp"
android:layout_height="60dp"
android:layout_gravity="center_horizontal"
android:text="Login" />
LinearLayout>
这里使用了一个新控件:CheckBox
。这是一个复选框控件,用户可以通过点击的方式进行选中和取消,我们就使用这个控件来表示用户是否需要记住密码。
然后修改LoginActivity
中的代码,如下所示:
class LoginActivity : AppCompatActivity() {
private lateinit var login: Button
private lateinit var accountEdit: EditText
private lateinit var passwordEdit: EditText
private lateinit var rememberPass: CheckBox
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_login)
login = findViewById(R.id.login)
accountEdit = findViewById(R.id.account_edit)
passwordEdit = findViewById(R.id.password_edit)
rememberPass = findViewById(R.id.rememberPass)
val prefs = getPreferences(Context.MODE_PRIVATE)
val isRemember = prefs.getBoolean("remember_password", false)
if (isRemember) {
val account = prefs.getString("account", "")
val password = prefs.getString("password", "")
accountEdit.setText(account)
passwordEdit.setText(password)
rememberPass.isChecked = true
}
login.setOnClickListener {
val account = accountEdit.text.toString()
val password = passwordEdit.text.toString()
if (account == "admin" && password == "123456") {
val editor = prefs.edit()
if (rememberPass.isChecked) {
editor.putBoolean("remember_password", true)
editor.putString("account", account)
editor.putString("password", password)
} else {
editor.clear()
}
editor.apply()
val intent = Intent(this, MainActivity::class.java)
startActivity(intent)
finish()
} else {
Toast.makeText(this, "account or password is invalid", Toast.LENGTH_LONG).show()
}
}
}
}
可以看到,这里首先在onCreate()
方法中获取了SharedPreferences
对象,然后调用它的getBoolean()
方法去获取remember_password
这个键对应的值。一开始当然不存在对应的值了,所以会使用默认值false
,这样就什么都不会发生。接着在登录成功之后,会调用CheckBox
的isChecked()
方法来检查复选框是否被选中。如果被选中了,则表示用户想要记住密码,这时将remember_password
设置为true
,然后把account
和password
对应的值都存入SharedPreferences
文件中并提交;如果没有被选中,就简单地调用一下clear()
方法, 将SharedPreferences
文件中的数据全部清除掉。
当用户选中了记住密码复选框,并成功登录一次之后,remember_password
键对应的值就是true
了,这个时候如果重新启动登录界面,就会从SharedPreferences
文件中将保存的账号和密码都读取出来,并填充到文本输入框中,然后把记住密码复选框选中,这样就完成记住密码的功能了。
不过需要注意,这里实现的记住密码功能仍然只是个简单的示例,不能在实际的项目中直接使用。因为将密码以明文的形式存储在SharedPreferences
文件中是非常不安全的,很容易被别人盗取,因此在正式的项目里必须结合一定的加密算法对密码进行保护才行。
SQLite
数据库存储SQLite
是一款轻量级的关系型数据库,它的运算速度非常快,占用资源很少,通常只需要几百KB
的内存就足够了,因而特别适合在移动设备上使用。SQLite
不仅支持标准的SQL
语法,还遵循了数据库的ACID
事务。SQLite
又比一般的数据库要简单得多,它甚至不用设置用户名和密码就可以使用。Android
正是把这个功能极为强大的数据库嵌入到了系统当中。
文件存储和SharedPreferences
存储毕竟只适用于保存一些简单的数据和键值对,当需要存储大量复杂的关系型数据的时候,你就会发现以上两种存储方式很难应付得 了。比如我们手机的短信程序中可能会有很多个会话,每个会话中又包含了很多条信息内容, 并且大部分会话还可能各自对应了通讯录中的某个联系人。很难想象如何用文件或者SharedPreference
s来存储这些数据量大、结构性复杂的数据。
Android
为了让我们能够更加方便地管理数据库,专门提供了一个SQLiteOpenHelper
帮助类,借助这个类可以非常简单地对数据库进行创建和升级。
SQLiteOpenHelper
是一个抽象类,这意味着如果想要使用它,就需要创建一个自己的帮助类去继承它。SQLiteOpenHelper
中有两个抽象方法:onCreate()
和onUpgrade()
。必须在自己的帮助类里重写这两个方法,然后分别在这两个方法中实现创建和升级数据库的逻辑。
SQLiteOpenHelper
中还有两个非常重要的实例方法:getReadableDatabase()
和getWritableDatabase()
。这两个方法都可以创建或打开一个现有的数据库(如果数据库已存在则直接打开,否则要创建一个新的数据库),并返回一个可对数据库进行读写操作的对象。不同的是,当数据库不可写入的时候(如磁盘空间已满),getReadableDatabase()
方法返回的对象将以只读的方式打开数据库,而getWritableDatabase()
方法则将出现异常。
SQLiteOpenHelper
中有两个构造方法可供重写,一般使用参数少一点的那个构造方法即可。 这个构造方法中接收4
个参数:
Context
,必须有它才能对数据库进行操作;Cursor
,一般传入null
即可;构建出SQLiteOpenHelpe
r的实例之 后,再调用它的getReadableDatabase()
或getWritableDatabase()
方法就能够创建数据库了,数据库文件会存放在/data/data/
目录下。此时,重写的onCreate()
方法也会得到执行,所以通常会在这里处理一些创建表的逻辑。
首先新建一个DatabaseTest
项目。这里我们希望创建一个名为BookStore.db
的数据库,然后在这个数据库中新建一张Book
表, 表中有id
(主键)、作者、价格、页数和书名等列。创建数据库表当然还是需要用建表语句的。Book
表的建表语句如下所示:
create table Book (
id integer primary key autoincrement,
author text,
price real,
pages integer,
name text
)
SQLite
不像其他的数据库拥有众多繁杂的数据类型,它的数据类型很简单:integer
表示整型,real
表示浮点型,text
表示文本类型,blob
表示二进制类型。另外,在上述建表语句中,我们还使用了primary key
将id
列设为主键,并用autoincrement
关键字表示id
列是自增长的。
然后需要在代码中执行这条SQL
语句,才能完成创建表的操作。新建MyDatabaseHelper
类继承自SQLiteOpenHelper
,代码如下所示:
class MyDatabaseHelper(private val context: Context, name: String, version: Int) :
SQLiteOpenHelper(context, name, null, version) {
private val createBook = "create table Book (" +
" id integer primary key autoincrement," +
"author text," +
"price real," +
"pages integer," +
"name text)"
override fun onCreate(db: SQLiteDatabase?) {
db?.execSQL(createBook)
Toast.makeText(context, "Create succeeded", Toast.LENGTH_SHORT).show()
}
override fun onUpgrade(db: SQLiteDatabase?, oldVersion: Int, newVersion: Int) {
}
}
可以看到,把建表语句定义成了一个字符串变量,然后在onCreate()
方法中又调用了SQLiteDatabase
的execSQL()
方法去执行这条建表语句,并弹出一个Toast
提示创建成功, 这样就可以保证在数据库创建完成的同时还能成功创建Book
表。
现在修改activity_main.xml
中的代码,如下所示:
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
tools:context=".MainActivity">
<Button
android:id="@+id/createDatabase"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="create Database" />
LinearLayout>
布局文件很简单,就是加入了一个按钮,用于创建数据库。最后修改MainActivity
中的代码, 如下所示:
class MainActivity : BaseActivity() {
private lateinit var createDatabase: Button
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
createDatabase = findViewById(R.id.createDatabase)
val dbHelper = MyDatabaseHelper(this, "BookStore.db", 1)
createDatabase.setOnClickListener {
dbHelper.writableDatabase
}
}
}
在onCreate()
方法中构建了一个MyDatabaseHelper
对象,并且通过构造函数的参数将数据库名指定为BookStore.db
,版本号指定为1
,然后在按钮的点击事件里调用getWritableDatabase()
方法。这样当第一次点击按钮时,就会检测到当前程序中并没有BookStore.db
这个数据库,于是会创建该数据库并调用MyDatabaseHelper
中的onCreate()
方法,这样Book
表也就创建好了,然后会弹出一个Toast
提示创建成功。再次点击按钮时,会发现此时已经存在BookStore.db
数据库了,因此不会再创建一次。
这里我们仍然还是可以使用Device File Explorer
,但是这个工具最多只能看到databases
目录下出现了一个BookStore.db
文件,是无法查看Book
表的。因此我们还需要借助一个叫作Database Navigator
的插件工具。
Android Studio
是基于IntelliJ IDEA
进行开发的,因此IntelliJ IDEA
中各种丰富的插件在Android Studio中
也可以使用。从Android Studio
导航栏中打开Preferences->Plugins
,就 可以进入插件管理界面了。
打开Device File Explorer
,然后进 入/data/data/com.example.databasetest/databases/
目录下,可以看到已经存在了一个 BookStore.db
文件,如图所示:
这个目录下还存在另外一个BookStore.db-journal
文件,这是一个为了让数据库能够支持事务而产生的临时日志文件,通常情况下这个文件的大小是0
字节,我们可以暂时不用管它。
现在对着BookStore.db
文件右击→Save As
,将它从模拟器导出到计算机的任意位置。然后观察Android Studio
的工具栏,现在应该多出了一个DB Navigator
工具,这就是我们刚刚安装的插件了,选择DataBase Browser
。为了打开刚刚导出的数据库文件,我们需要点击这个工具左上角的加号按钮,并选择SQLite
选 项,如图所示:
然后在弹出窗口的Database
配置中选择我们刚才导出的BookStore.db
文件,如图所示:
点击OK
完成配置,这个时候DB Browser
中就会显示出BookStore.db
数据库里所有的内容了,如图所示:
可以看到,BookStore.db
数据库中确实存在了一张Book
表,并且Book
表中的列也和我们前面使用的建表语句完全匹配,由此证明BookStore.db
数据库和Book表
确实已经创建成功了。
在MyDatabaseHelper
中还有一个方法onUpgrade()
方法是用于对数据库进行升级的,它在整个数据库的管理工作当中起着非常重要的作用。
目前,DatabaseTest
项目中已经有一张Book
表用于存放书的各种详细数据,如果我们想再添 加一张Category
表用于记录图书的分类。比如Category
表中有id(主键)
、分类名和分类代码这几个列,那么建表语句就可以写成:
create table Category (
id integer primary key autoincrement,
category_name text,
category_code integer)
将这条建表语句添加到MyDatabaseHelper
中,代码如下所示:
class MyDatabaseHelper(private val context: Context, name: String, version: Int) :
SQLiteOpenHelper(context, name, null, version) {
private val createBook = "create table Book (" +
" id integer primary key autoincrement," +
"author text," +
"price real," +
"pages integer," +
"name text)"
private val createCategory = "create table Category (" +
"id integer primary key autoincrement," +
"category_name text," +
"category_code integer)"
override fun onCreate(db: SQLiteDatabase?) {
db?.execSQL(createBook)
db?.execSQL(createCategory)
Toast.makeText(context, "Create succeeded", Toast.LENGTH_SHORT).show()
}
override fun onUpgrade(db: SQLiteDatabase?, oldVersion: Int, newVersion: Int) {
}
}
重新运行一下程序,并点击按钮,没有弹出创建成功的提示。当然,通过DB Browser
工具到数据库中再去检查一 下,Category
表没有创建成功。
其实没有创建成功的是因为此时BookStore.db
数据库已经存在了,之后不管怎样点击按钮,MyDatabaseHelper
中的onCreate()
方法都不会再次执行,因此新添加的表也就无法得到创建了。
解决这个问题的办法也相当简单,只需要先将程序卸载,然后重新运行,这时BookStore.db
数 据库已经不存在了,如果再点击按钮,MyDatabaseHelper
中的onCreate()
方法就会执行,这时Category
表就可以创建成功了。不过,通过卸载程序的方式来新增一张表毫无疑问是很极端的做法,其实可以运用SQLiteOpenHelper
的升级功能,就可以很轻松地解决这个问题。
修改MyDatabaseHelper
中的代码,如下所示:
override fun onUpgrade(db: SQLiteDatabase?, oldVersion: Int, newVersion: Int) {
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
中的代码,如下所示:
val dbHelper = MyDatabaseHelper(this, "BookStore.db", 2)
这里将数据库版本号指定为2
,表示我们对数据库进行升级了。现在重新运行程序,并点击按钮,这时就会再次弹出创建成功的提示。
为了验证一下Category
表是不是已经创建成功了,还可以使用同样的方式将BookStore.db
文件导出到计算机本地,并覆盖之前的BookStore.db
文件,然后在DB Browser
中重新导入,这样就会加载新的BookStore.db
文件了,如图所示:
可以看到,Category
表已经创建成功了,说明升级功能的确起到了作用。
我们可以对数据进行的操作无非有4
种,即CRUD
。其中C
代表添加(create)
,R
代表查询(retrieve)
,U
代表更新(update)
,D
代表删除(delete)
。每一种操作都对应了一种SQL
命令,在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
中的代码,如下所示:
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
tools:context=".MainActivity">
<Button
android:id="@+id/addData"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Add Data" />
LinearLayout>
接着修改MainActivity
中的代码,如下所示:
addData.setOnClickListener {
val db = dbHelper.writableDatabase
val value1 = ContentValues().apply {
// 开始组装第一条数据
put("name", "The Da Vinci Code")
put("author", "Dan Brown")
put("pages", 454)
put("price", 16.96)
}
db.insert("Book", null, value1) // 插入第一条数据
val value2 = ContentValues().apply {
// 开始组装第二条数据
put("name", "The Lost Symbol")
put("author", "Dan Brown")
put("pages", 510)
put("price", 19.95)
}
db.insert("Book", null, value2) // 插入第二条数据
}
在添加数据按钮的点击事件里,我们先获取了SQLiteDatabase
对象,然后使用ContentValues
对要添加的数据进行组装。在这里只对Book
表里其中4
列的数据进行了组装,id
那一列并没给它赋值。这是因为在前面创建表的时候,就将id
列设置为自增长了,它的值会在入库的时候自动生成,所以不需要手动赋值了。接下来调用了insert()
方法将数据添加到表当中。
重新运行程序,点击按钮,此时两条数据应该都已经添加成功了。可以使用DB Browser
来验证一下,同样先将BookStore.db
文件导出到本地,然后重新加载数据库,想要查 哪张表的内容,只需要双击这张表就可以了,这里双击Book
表,会弹出一个如图所示的窗口:
这个窗口是用来设置查询条件的,这里我们不需要设置任何查询条件,直接点击窗口下方的No Filter
按钮即可,然后就可以看到如图所示的数据了:
SQLiteDatabase
中提供了一个update()
方法,用于对数据进行更新。 这个方法 接收4
个参数:
insert()
方法一样,也是表名,指定更新哪张表里的数据;ContentValues
对象,要把更新数据在这里组装进去;首先修改activity_main.xml
中的代码,如下所示:
<Button
android:id="@+id/updateData"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Update Data" />
然后修改MainActivity
中的代码,如下所示:
updateData.setOnClickListener {
val db = dbHelper.writableDatabase
val values = ContentValues()
values.put("price", 10.99)
db.update("Book", values, "name = ?", arrayOf("The Da Vinci Code"))
}
这里在更新数据按钮的点击事件里面构建了一个ContentValues
对象,并且只给它指定了一组数据,说明我们只是想把价格这一列的数据更新成10.99
。然后调用了SQLiteDatabase
的update()
方法执行具体的更新操作,可以看到,这里使用了第三、第四个参数来指定具体更新 哪几行。第三个参数对应的是SQL
语句的where
部分,表示更新所有name
等于?
的行,而?
是一 个占位符,可以通过第四个参数提供的一个字符串数组为第三个参数中的每个占位符指定相应 的内容,arrayOf()
方法是Kotlin
提供的一种用于便捷创建数组的内置方法。因此上述代码想表达的意图就是将The Da Vinci Code
这本书的价格改成10.99
。
SQLiteDatabase
中提供了一个delete()
方法,专门用于删除数据。这个方法接收3
个参数:
修改activity_main.xml
中的代码,如下所示:
<Button
android:id="@+id/deleteData"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Delete Data" />
然后修改MainActivity
中的代码,如下所示:
deleteData.setOnClickListener {
val db = dbHelper.writableDatabase
db.delete("Book", "pages > ?", arrayOf("500"))
}
在删除按钮的点击事件里指明删除Book
表中的数据,并且通过第二、第三个参 数来指定仅删除那些页数超过500
页的书。
SQ
L的全称是Structured Query Language
,翻译成中文就是结构化查询语言。它的大部分功能体现在“查”这个字上,而“增删改”只是其中的一小部分功能。
**在SQLiteDatabase
中还提供了一个query()
方法用于对数据进行查询。**这个方法的参数非常复杂,最短的一个方法重载也需要传入7
个参数:
group by
的列,不指定则表示不对查询结果进行group by
操作;group by
之后的数据进行进一步的过滤,不指定则表示不进行过滤;更多详细的内容:
虽然query()
方法的参数非常多,但是我们不必为每条查询语句都指定所有的参数,多数情况下只需要传入少数几个参数就可以完成查询操作了。调用query()
方 法后会返回一个Cursor
对象,查询到的所有数据都将从这个对象中取出。
修改activity_main.xml
中的代 码,如下所示:
<Button
android:id="@+id/queryData"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Query Data" />
然后修改MainActivity
中的代码, 如下所示:
queryData.setOnClickListener {
val db = dbHelper.writableDatabase
val cursor = db.query("Book", null, null, null, null, null, null)
if (cursor.moveToFirst()) {
do {
val name = cursor.getString(cursor.getColumnIndex("name"))
val author = cursor.getString(cursor.getColumnIndex("author"))
val pages = cursor.getString(cursor.getColumnIndex("pages"))
val price = cursor.getString(cursor.getColumnIndex("price"))
Log.e("db", "Book name is $name")
Log.e("db", "Book author is $author")
Log.e("db", "Book pages is $pages")
Log.e("db", "Book price is $price")
} while (cursor.moveToNext())
}
cursor.close()
}
可以看到,在查询按钮的点击事件里面调用了SQLiteDatabase
的query()
方法查询 数据。这里的query()
方法非常简单,只使用了第一个参数指明查询Book
表,后面的参数全部为null
。这就表示希望查询这张表中的所有数据,虽然这张表中目前只剩下一条数据了。查询 完之后就得到了一个Curso
r对象,接着我们调用它的moveToFirst()
方法,将数据的指针移动到第一行的位置,然后进入一个循环当中,去遍历查询到的每一行数据。在这个循环中可以通过Cursor
的getColumnIndex()方
法获取某一列在表中对应的位置索引,然后将这个索引 传入相应的取值方法中,就可以得到从数据库中读取到的数据了。最后别忘了调用close()
方法来关闭Cursor
。
SQL
操作数据库虽然Android
提供了很多非常方便的API
用于操作数据库,不过也可以直接使用SQL
来操作数据库。
添加数据:
db.execSQL("insert into Book (name, author, pages, price) values(?, ?, ?, ?)",
arrayOf("The Da Vinci Code", "Dan Brown", "454", "16.96"))
db.execSQL("insert into Book (name, author, pages, price) values(?, ?, ?, ?)",
arrayOf("The Lost Symbol", "Dan Brown", "510", "19.95"))
更新数据:
db.execSQL("update Book set price = ? where name = ?", arrayOf("10.99", "The Da Vinci Code"))
删除数据:
db.execSQL("delete from Book where pages > ?", arrayOf("500"))
查询数据:
val cursor = db.rawQuery("select * from Book", null)
可以看到,除了查询数据的时候调用的是SQLiteDatabase
的rawQuery()
方法,其他操作都是调用的execSQL()
方法。
SQLite
数据库实践SQLite
数据库是支持事务的,事务的特性可以保证让一系列的操作要么全部完成,要么一个都不会完成。 那么在什么情况下才需要使用事务呢?比如你正在进行 一次转账操作,银行会先将转账的金额从你的账户中扣除,然后再向收款方的账户中添加等量的金额。看上去好像没什么问题。可是,如果当你账户中的金额刚刚被扣除,这时由于一些异常原因导致对方收款失败,这一部分钱就凭空消失了。当然银行肯定已经充分考虑到了这种情况,它会保证扣款和收款的操作要么一起成功,要么都不会成功,而使用的技术就是事务了。
在Android
中也有事务,比如Book
表中的数据已经很老了,现在准备全部废弃,替换成新数据,可以先使用delete()
方法将Book
表中的数据删除,然后再使用insert()
方法将新的数据添加到表中。 我们要保证删除旧数据和添加新数据的操作必须一起完成,否则就要继续保留原来的旧数据。 修改activity_main.xml
中的代码,如下所示:
<Button
android:id="@+id/replaceData"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Replace Data" />
然后修改MainActivity
中的代 码,如下所示:
replaceData.setOnClickListener {
val db = dbHelper.writableDatabase
db.beginTransaction() // 开启事务
try {
db.delete("Book", null, null)
if (true) {
throw NullPointerException() // 手动抛出一个异常,让事务失败
}
val values = ContentValues().apply {
put("name", "Game of Thrones")
put("author", "George Martin")
put("pages", 720)
put("price", 20.85)
}
db.insert("Book", null, values)
db.setTransactionSuccessful() // 事务已经执行成功
} catch (e: Exception) {
e.printStackTrace()
} finally {
db.endTransaction() // 结束事务
}
}
上述代码就是Android
中事务的标准用法,首先调用SQLiteDatabase
的beginTransaction()
方法开启一个事务,然后在一个异常捕获的代码块中执行具体的数据库操作,当所有的操作都完成之后,调用setTransactionSuccessful()
表示事务已经执行成功了,最后在finally
代码块中调用endTransaction()
结束事务。注意观察,我们在删除旧数据的操作完成后手动抛出了一个NullPointerException
,这样添加新数据的代码就执行不到了。不过由于事务的存在,中途出现异常会导致事务的失败,此时旧数据应该是删除不掉的。
现在运行一下程序并点击Replace Data
按钮,然后点击Query Data
按钮。你会发现,Book
表中存在的还是之前的旧数据,说明我们的事务确实生效了。然后将手动抛出异常的那行代码删除并重新运行程序,此时点击一下Replace Data
按钮,就会将Book
表中的数据替换 成新数据了,你可以再使用Query Data
按钮来验证一次。
直接使用升级版本号方法升级数据库的方式是非常粗暴的,为了保证数据库中的表是最新的, 我们只是简单地在onUpgrade()
方法中删除掉了当前所有的表,然后强制重新执行了一遍onCreate()
方法。这种方式在产品的开发阶段确实可以用,但是当产品真正上线之后就绝对 不行了。想象以下场景,比如你编写的某个应用已经成功上线了,并且还拥有了不错的下载量。现在由于添加了新功能,数据库需要一起升级,结果用户更新了这个版本之后却发现以前程序中存储的本地数据全部丢失了。
其实只需要进行一些合理的控制,就可以保证在升级数据库的时候数据并不会丢失了。
每一个数据库版本都会对应一个版本号,当指定的数据库版本号大于当前数据库版本号的时候,就会进入onUpgrade()
方法中执行更新操作。这里需要为每一个版本号赋予其所对应的数据库变动,然后在onUpgrade()
方法中对当前数据库的版本号进行判断,再执行相应的改变就可以了。
下面来实现一下, 第1
版的程序要求非常简单,只需要创建一张Book
表。MyDatabaseHelper
中的代码如下所示:
class MyDatabaseHelper(private val context: Context, name: String, version: Int) :
SQLiteOpenHelper(context, name, null, version) {
private val createBook = "create table Book (" +
" id integer primary key autoincrement," +
"author text," +
"price real," +
"pages integer," +
"name text)"
override fun onCreate(db: SQLiteDatabase?) {
db?.execSQL(createBook)
}
override fun onUpgrade(db: SQLiteDatabase?, oldVersion: Int, newVersion: Int) {
}
}
不过,几星期之后又有了新需求,这次需要向数据库中再添加一张Category
表。于是,修改MyDatabaseHelper
中的代码,如下所示:
class MyDatabaseHelper(private val context: Context, name: String, version: Int) :
SQLiteOpenHelper(context, name, null, version) {
private val createBook = "create table Book (" +
" id integer primary key autoincrement," +
"author text," +
"price real," +
"pages integer," +
"name text)"
private val createCategory = "create table Category (" +
"id integer primary key autoincrement," +
"category_name text," +
"category_code integer)"
override fun onCreate(db: SQLiteDatabase?) {
db?.execSQL(createBook)
db?.execSQL(createCategory)
}
override fun onUpgrade(db: SQLiteDatabase?, oldVersion: Int, newVersion: Int) {
if (oldVersion <= 1) {
db?.execSQL(createCategory)
}
}
}
可以看到,在onCreate()
方法里我们新增了一条建表语句,然后又在onUpgrade()
方法中添加了一个if
判断,如果用户数据库的旧版本号小于等于1
,就只会创建一张Category
表。
这样当用户直接安装第2
版的程序时,就会进入onCreate()
方法,将两张表一起创建。而当用户使用第2
版的程序覆盖安装第1
版的程序时,就会进入升级数据库的操作中,此时由于Book
表已经存在了,因此只需要创建一张Category
表即可。
但是没过多久,新的需求又来了,这次要给Book
表和Category
表之间建立关联,需要在Book
表中添加一个category_id
字段。再次修改MyDatabaseHelper
中的代码,如下所示:
class MyDatabaseHelper(private val context: Context, name: String, version: Int) :
SQLiteOpenHelper(context, name, null, version) {
private val createBook = "create table Book (" +
" id integer primary key autoincrement," +
"author text," +
"price real," +
"pages integer," +
"name text," +
"category_id integer)"
private val createCategory = "create table Category (" +
"id integer primary key autoincrement," +
"category_name text," +
"category_code integer)"
override fun onCreate(db: SQLiteDatabase?) {
db?.execSQL(createBook)
db?.execSQL(createCategory)
}
override fun onUpgrade(db: SQLiteDatabase?, oldVersion: Int, newVersion: Int) {
if (oldVersion <= 1) {
db?.execSQL(createCategory)
}
if (oldVersion <= 2) {
db?.execSQL("alter table Book add column category_id integer")
}
}
}
可以看到,首先我们在Book
表的建表语句中添加了一个category_id
列,这样当用户直接安装第3
版的程序时,这个新增的列就已经自动添加成功了。然而,如果用户之前已经安装了某一版本的程序,现在需要覆盖安装,就会进入升级数据库的操作中。在onUpgrade()
方法里,我们添加了一个新的条件,如果当前数据库的版本号是2
,就会执行alter
命令,为Book
表新增 一个category_id
列。
这里请注意一个非常重要的细节:每当升级一个数据库版本的时候,onUpgrade()
方法里都一定要写一个相应的if
判断语句。这是为了保证APP
在跨版本升级的时候, 每一次的数据库修改都能被全部执行。 比如用户当前是从第2
版升级到第3
版,那么只有第二条判断语句会执行,而如果用户是直接从第1
版升级到第3
版,那么两条判断语句都会执行。使用这种方式来维护数据库的升级,不管版本怎样更新,都可以保证数据库的表结构是最新的,而 且表中的数据完全不会丢失。
现在Google
又推出了一个专门用于Android
平台的数据库框架——Room
。相比于传统的数据库API
,Room
的用法要更加复杂一些,但是却更加科学和规范,也更加符合现代高质量APP
的开发标准。