详解 Android 数据持久化技术

559 阅读10分钟

前言

应用程序的核心就是在操控数据,我们之前也有用到数据,只不过那些数据是瞬时数据。它们存在于内存中,会因程序关闭而丢失。为了保证一些关键的数据不会因应用程序的退出或是设备重启而丢失,我们就要用到数据持久化技术了。

持久化技术简介

数据持久化,就是将内存中的瞬时数据保存到存储设备中,使其变为持久数据,这样,即使设备重启、程序关闭,这些数据也不会丢失。

在内存中的数据处于瞬时状态,而保存在存储设备中的数据处于持久状态持久化就是一套可以让程序数据在瞬时状态和持久状态之间进行转换的机制。

接下来,我们来讲解安卓中三种数据持久化的方式。

文件存储

文件存储是 Android 中最基本的数据存储方式。它不会对存储的内容做任何格式化的处理,会将数据原封不动以字节流的形式保存到文件中。

这种方式非常适合存储一些较为简单的文本数据(如 JSON、XML)或是二进制数据(如图片)。如果你要用它存储较为复杂的结构化数据,就需要自定义一套格式规范,以便后续且正确且方便地从文件中解析数据。

将数据存储到文件中

我们可以通过 Context 类提供的 openFileOutput() 方法将数据存储到指定的文件中。

该方法接收两个参数:

  • name: String:指定文件名。这里我们并不需要指定文件路径,因为 Android 系统会将文件默认存储到 /data/data/<package name>/files/ 目录中。这是每个应用内部的专属目录,只有应用内部可以访问,非常安全。

  • mode: Int:文件的操作模式,可选的模式有 MODE_PRIVATEMODE_APPEND,默认是 MODE_PRIVATE 模式。

    MODE_PRIVATE 模式下,当指定的文件已存在时,会覆盖文件的原有内容;而在 MODE_APPEND 模式中,会往原有文件末尾追加内容。在指定的文件不存在时,会创建新的文件。

    本来还有 MODE_WORLD_READABLEMODE_WORLD_WRITEABLE 模式,表示允许其他应用对当前程序中的数据进行读写操作,但由于存在严重安全风险,已经被废弃了。

openFileOutput() 方法会返回一个 FileOutputStream 对象,我们可以操作这个对象,来将数据写入到文件中。

文件读写是耗时的 IO 操作,如果在主线程(UI线程)进行这些操作,可能会导致界面卡顿,出现应用无响应(ANR)错误。所以我们要在后台线程中执行这些操作。

例如,将一段文本内容保存到文件中:

fun save(inputText: String) {
    // 启动一个协程,并且在 Dispatchers.IO 线程池中执行文件写入操作
    lifecycleScope.launch(Dispatchers.IO) {
        try {
            // 获取 FileOutputStream 对象
            val output = openFileOutput("data", Context.MODE_PRIVATE)
            // 包装为 BufferedWriter 对象
            val writer = BufferedWriter(OutputStreamWriter(output))
            // 使用 use 函数确保流在操作结束后被自动关闭
            writer.use {
                // 写入数据到文件中
                it.write(inputText)
            }
        } catch (e: IOException) {
            e.printStackTrace()
        }
    }
}

注意:

  1. use 是 Kotlin 标准库中提供的扩展函数,它可以保证在 Lambda 中的代码执行完毕后,会自动关闭调用对象(实现了Closeable接口的各种输入输出流)。这可以让我们无需加上 finally 块来手动关闭流了。

  2. 另外,Kotlin 没有像 Java 那样的异常检查机制的。这意味着,即使我们不使用上面的 try...catch 语句来捕获 IOException 异常,代码也不会报错,仍然可以编译通过。

从文件中读取数据

相应地,从文件中读取数据可以使用 Context 类提供的 openFileInput() 方法。这个方法只接收一个 name 参数,表示从哪个文件中读取数据。

该方法会返回一个 FileInputStream 对象,我们可以操作这个对象,来从文件中读取数据。

例如,将文件中的文本内容读取出来:

fun load() {
    lifecycleScope.launch(Dispatchers.IO) {
        val content = StringBuilder()
        try {
            // 直接获取 BufferedReader 对象
            val bufferedReader = openFileInput("data").bufferedReader()

            bufferedReader.use { reader ->
                // 获取文件中的每一行内容
                reader.forEachLine {
                    content.append(it)
                }
            }

//                // 等效于上面的写法
//                bufferedReader.useLines { lines ->
//                    lines.forEach { content.append(it) }
//                }

        } catch (e: IOException) {
            e.printStackTrace()
        }

        Log.d("load()", content.toString())
    }

}

其中我们使用了 BufferedReaderforEachLine 扩展函数来一行行地读取文件。并且它在遍历的过程中,会将读取到的每一行内容作为参数传入到 Lambda 表达式中。

当然你可以使用 InputStream 对象的 reader().readText() 扩展函数,来读取文件中的所有文本内容,也能自动关闭流,只需这一行代码即可获取文件中的文本内容:

val text = openFileInput("data").reader().readText()

实现保存并恢复用户输入功能

下面我们来通过一个完整的例子:实现用户输入恢复功能,来演示如何在项目中使用文件存储技术。

首先,创建一个名为 FilePersistenceTestEmpty Views Activity 项目,往 activity_main 布局中添加一个 EditText 输入框:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical">

    <EditText
        android:id="@+id/editText"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:hint="Type something here"
        android:importantForAutofill="no"
        android:inputType="none" />

</LinearLayout>

目前我们运行程序,在输入框输入的内容,会在程序退出后丢失,重新打开程序,发现输入框中没有任何内容留存,是空的。

image.png

因为这些数据是瞬时数据,处于内存中,会在当前所在进程被杀死后,被系统回收。

所以我们可以在 onStop() 回调中保存数据,也就是在界面对用户来说不可见时。在 onCreate() 方法中加载数据,也就是在应用启动时。

为什么不在 onDestroy() 方法中保存数据?

因为系统不保证 onDestroy() 方法一定会被调用。比如在因内存不足,系统直接杀死当前进程的时候;用户强制停止当前应用的时候;或因应用出现未捕获到异常,导致应用崩溃的时候。onStop() 回调会更可靠些。

MainActivity 中的代码如下:

class MainActivity : AppCompatActivity() {

    private lateinit var binding: ActivityMainBinding

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)

        // 在 Activity 创建时,加载输入框的文本数据,并且填充到输入框中
        loadAndShow()

    }

    override fun onStop() {
        super.onStop()
        // 当 Activity 对用户来说不可见时,在后台保存输入框中的内容
        val inputText = binding.editText.text.toString()
        save(inputText)
    }

    private fun save(inputText: String) {
        // 启动一个协程,并且在 Dispatchers.IO 线程池中执行文件写入操作
        lifecycleScope.launch(Dispatchers.IO) {
            try {
                // 获取 FileOutputStream 对象
                val output = openFileOutput("data", Context.MODE_PRIVATE)
                // 包装为 BufferedWriter 对象
                val writer = BufferedWriter(OutputStreamWriter(output))

                writer.use {
                    // 写入数据到文件中
                    it.write(inputText)
                }
            } catch (e: IOException) {
                e.printStackTrace()
            }
        }
    }

    private fun loadAndShow() {
        lifecycleScope.launch(Dispatchers.IO) {
            // 获取文件存储的输入框文本数据
            val content = StringBuilder()
            try {
                openFileInput("data").bufferedReader().useLines { lines ->
                    lines.forEach { content.append(it) }
                }
            } catch (e: IOException) {
                e.printStackTrace()
            }

            // 读取完成后,切换回主线程更新 UI
            withContext(Dispatchers.Main) {
                val loadedText = content.toString()
                if (loadedText.isNotEmpty()) {
                    binding.editText.setText(loadedText)
                    binding.editText.setSelection(loadedText.length)
                    Toast.makeText(this@MainActivity, "Restoring succeeded", Toast.LENGTH_SHORT).show()
                }
            }
        }
    }
}

我们在 MainActivityonStop() 方法中,获取输入框的文本内容,并且调用 save() 方法将内容写入到文件中。

MainActivityonCreate 方法中,调用了 loadAndShow() 方法从文件中读取了文本内容,并且填充到输入框中。其中我们使用了 withContext(Dispatchers.Main),它能将代码切换到了主线程中去执行,因为所有对 UI 的操作都必须在主线程中执行。

现在,用户输入恢复功能就实现了。

重新运行程序,随便往输入框中输入一些内容,然后按下返回键退出程序。

image.png

此时我们输入的内容就已经保存到了 data 文件中,文件的路径是 \data\data\com.example.filepersistencetest\files\data

如何验证呢?借助 Android Studio 的 Device Explorer 工具(在菜单栏 View->Tool Windows 下可以找到)。

image.png

双击文件查看内容:

image.png

然后重启程序。你会惊奇地发现,输入框竟然有着我们上一次输入的内容。

image.png

SharedPreferences 存储

当需要保存的数据是键值对时,比如应用的配置项或者是用户的偏好设置,SharedPreferences 会是更好的选择,它会以 XML 文件的格式来存储数据,并且 API 简单易用。

SharedPreferences 支持多种数据类型的存储:比如 StringIntBooleanSet<String> 等,你存储的是什么类型,取出的就是什么类型。

将数据存储到 SharedPreferences 中

获取 SharedPreferences 对象的方式有两种:

  • Context 类的 getSharedPreferences(name: String, mode: Int) 方法

    第一个参数用于指定文件名,如果文件不存在则会新建一个。SharedPreferences 文件默认存放在 /data/data/<package name>/shared_prefs/ 目录下。

    第二个参数用于指定操作模式,目前只有 MODE_PRIVATE 这一种操作模式可选,表示只有当前程序可以对这些文件进行读写操作。其余三种均被废弃,分别是:MODE_WORLD_READABLEMODE_WORLD_WRITEABLEMODE_MULTI_PROCESS

  • Activity 类的 getPreferences(mode: Int) 方法

    这个方法只接收一个参数,就是操作模式。因为它会将当前 Activity 的类名作为 SharedPreferences 文件的文件名,适合存储和特定 Activity 绑定的数据。

有了 SharedPreferences 对象后,我们就可以开始存储数据了,主要分为三步:

  1. 调用 SharedPreferences 对象的 edit() 方法来获取 SharedPreferences.Editor 对象。

  2. 使用 Editor 对象的一系列 putXxx() 方法来添加数据,比如添加字符串类型的数据可以使用 putString() 方法。

  3. 数据添加完成后,调用 Editor 对象的 apply() 或者 commit() 方法提交数据。

我们看到提交数据有两种方法,其中:

  • apply():是异步提交,它会立即修改处于内存中的 SharedPreferences对象,但会将写入磁盘的操作放在后台线程中执行。一般推荐使用这个方法,因为不会阻塞到主线程。

  • commit()同步提交,它会阻塞当前线程,直到数据被完全写入到磁盘中。它具有返回值,返回 true 表示提交成功。请不要在主线程中调用该方法,否则可能会导致应用无响应。

我们来看一个例子,往布局中添加一个 "Save Data" 按钮,其点击的逻辑是:

// 给按钮注册点击事件
binding.saveButton.setOnClickListener {
    val sharedPreferences = getSharedPreferences("data", Context.MODE_PRIVATE)
    val editor = sharedPreferences.edit()

    editor.putString("name", "Martin")
    editor.putInt("age", 21)
    editor.putString("phone_number", "18888888888")

    editor.apply()
}

点击该按钮后,就能够保存数据。

再次借助 Device Explorer 工具来验证,查看 \data\data\<package_name>\shared_prefs\data.xml 文件中的内容:

<?xml version='1.0' encoding='utf-8' standalone='yes' ?>
<map>
    <string name="name">Martin</string>
    <string name="phone_number">18888888888</string>
    <int name="age" value="21" />
</map>

从 SharedPreferences 中读取数据

SharedPreferences 对象提供了一系列 get() 方法来让我们读取数据,比如获取字符串类型的数据可以使用 getString() 方法。

这些方法都接收两个参数,key 参数是数据存储时使用的键,defValue 参数是默认值,表示当指定的键不存在时,返回此默认值。

往布局中添加一个 "Restore Data" 按钮,其点击的逻辑是:

// 在按钮点击事件中
binding.restoreButton.setOnClickListener {
    val prefs = getSharedPreferences("data", Context.MODE_PRIVATE)
    val name = prefs.getString("name", "")
    val age = prefs.getInt("age", 0)
    val phoneNumber = prefs.getString("phone_number","")
    Log.d("MainActivity", "name is $name, age is $age, phone number is $phoneNumber")
}

运行程序后,点击该按钮就能从 SharedPreferences 文件中读取数据,可以在日志中看到:

"MainActivity", "name is Martin, age is 21, phone number is 18888888888"

实现记住密码功能

我们新建一个名为 SharedPreferencesTestEmpty Views Activity 项目,来演示一下如何实现登录时记住密码的功能。

先完成登录界面的布局,activity_main.xml 文件中的代码如下:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:gravity="center"
    android:orientation="vertical"
    android:paddingLeft="32dp"
    android:paddingRight="32dp">

    <EditText
        android:id="@+id/accountEdit"
        android:layout_width="match_parent"
        android:layout_height="56dp"
        android:autofillHints="username"
        android:hint="Account"
        android:inputType="text"
        android:paddingLeft="12dp"
        android:paddingRight="12dp"
        android:textSize="18sp" />

    <EditText
        android:id="@+id/passwordEdit"
        android:layout_width="match_parent"
        android:layout_height="56dp"
        android:layout_marginTop="16dp"
        android:autofillHints="password"
        android:hint="Password"
        android:inputType="textPassword"
        android:paddingLeft="12dp"
        android:paddingRight="12dp"
        android:textSize="18sp" />

    <CheckBox
        android:id="@+id/rememberPass"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="start"
        android:layout_marginTop="24dp"
        android:text="Remember password"
        android:textSize="16sp" />

    <Button
        android:id="@+id/login"
        android:layout_width="match_parent"
        android:layout_height="60dp"
        android:layout_marginTop="32dp"
        android:text="Login"
        android:textSize="18sp"
        android:textStyle="bold" />

</LinearLayout>

其中 CheckBox 是一个复选框,用户可以选择选中或者取消选中,来决定此次登录是否需要“记住密码”。

效果图:

image.png

MainActivity 中的代码如下:

class MainActivity : AppCompatActivity() {

    private lateinit var binding: ActivityMainBinding
    private lateinit var preferences: SharedPreferences

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)

        // 获取 SharedPreferences 对象
        preferences = getPreferences(MODE_PRIVATE)

        // 检查用户上次登录时是否勾选了记住密码
        val isRemember = preferences.getBoolean("remember_password", false)
        if (isRemember) {
            // 将账号和密码都填充到输入框中
            val account = preferences.getString("account", "")
            val password = preferences.getString("password", "")
            binding.accountEdit.setText(account)
            binding.passwordEdit.setText(password)
            binding.rememberPass.isChecked = true
        }


        // 给登录按钮注册点击事件
        binding.login.setOnClickListener {
            // 获取用户输入的账号和密码
            val account = binding.accountEdit.text.toString()
            val password = binding.passwordEdit.text.toString()

            if (account == "admin" && password == "123456") { // 模拟登录成功

                val editor = preferences.edit()
                // 用户勾选了复选框,保存账号、密码和标记
                if (binding.rememberPass.isChecked) {
                    editor.putBoolean("remember_password", true)
                    editor.putString("account", account)
                    editor.putString("password", password)
                } else {
                    editor.putBoolean("remember_password", false)
                    editor.remove("account")
                    editor.remove("password")
                }

                editor.apply()

                // 弹出登录成功的提示
                Toast.makeText(this, "Login successfully", Toast.LENGTH_SHORT).show()

                // 使用协程实现:延迟 3s 后关闭当前界面的效果
                lifecycleScope.launch {
                    delay(3000)
                    finish()
                }

            } else {
                Toast.makeText(this, "account or password is invalid", Toast.LENGTH_SHORT).show()
            }

        }
        

    }
}

可以看到,在 onCreate() 方法中,我们会从 SharedPreferences 文件中获取键为 remember_password 的数据,如果为 true,就将上一次成功登录保存的账号和密码自动填充到输入框中。

我们在用户成功登录后,会判断用户是否选中了“记住密码”复选框,如果选中,那么会将“记住密码”的标志以及用户的账号和密码存入到 SharedPreferences 文件。反之,没有选中,则会清除之前 SharedPreferences 文件中存储的相关数据。

重新运行程序,如图所示:

image.png

我们输入正确的账号(admin)和密码(123456),并且勾选上记住密码复选框。点击登录后,会弹出 Toast 提示“登录成功”,并且在 3 秒后会退出程序。

再次打开程序,可以看到账号、密码已经被自动填充到界面中了,并且会自动勾选上记住密码复选框。

image.png

注意:这里只是个简单的示例,我们将密码进行明文存储了。实际项目中,会使用加密算法对密码进行保护,例如使用 Jetpack Security 库提供的 EncryptedSharedPreferences

现代化的选择:DataStore

现在,我们更多会选择 DataStore 来替代 SharedPreferences,因为 SharedPreferences 存在着一些问题,比如第一次访问时可能会阻塞主线程(在 UI 线程上执行 IO)、API非类型安全、缺少事务性支持等,DataStore 基于 Kotlin 协程和 Flow,解决了这些问题。对于键值对数据的存储来说,应该优先考虑使用 DataStore。

SQLite 数据库存储

当面对大量复杂的结构化数据时,文件存储和 SharedPreferences 存储就不太适合了,这时就要使用到关系型数据库。Android 系统内置了 SQLite 数据库,这是一款轻量级的关系型数据库,它性能很高,使用简单,还不用配置用户名和密码。

注意:在现代 Android 开发中,强烈推荐使用 Room 持久性库。它在 SQLite 之上提供了对象映射(ORM)层,可以在编译时期就验证 SQL 语句,简化了数据库操作(如数据库的升级过程)。

创建数据库

Android 提供了 SQLiteOpenHelper 抽象类来帮助我们管理数据库的创建和版本升级。

因为它是一个抽象类,我们需要继承它,并实现其内部的两个必须要实现的抽象方法,onCreate() 方法用于首次创建数据库,onUpgrade() 方法升级数据库的版本。

SQLiteOpenHelper 还有两个重要的实例方法:getReadableDatabase()getWritableDatabase()。它们都可以创建或打开一个数据库,并返回一个可对数据库进行读写操作的 SQLiteDatabase 对象。两者唯一的区别是:当数据库不可进行写入时(如磁盘空间已满),getReadableDatabase() 方法返回的是一个只读对象,而 getWritableDatabase() 方法会直接抛出异常。

SQLiteOpenHelper 的构造函数最常用的是 SQLiteOpenHelper(Context context, String name, CursorFactory factory, int version),其中:

  • context:Context 上下文对象。

  • name:数据库文件名。

  • factory:用于创建自定义 Cursor 对象,一般传入 null 即可。

  • version:数据库的版本号,可用于触发对数据库的升级

创建数据库的流程:

创建 SQLiteOpenHelper 的实例,然后调用其 getReadableDatabase() 或者 getWritableDatabase()。这时会检查数据库文件是否存在,如果不存在,会创建数据库并调用其 onCreate() 方法,我们会在该方法下,创建数据表。如果已存在,则会对比版本号,若构造函数中传入的版本号更高,则会调用 onUpgrade() 方法。数据库文件默认会存放在 /data/data/<package name>/databases/ 目录下。

我们还是来实操一下,新建名为 DatabaseTestEmpty Views Activity 项目。

首先创建 MyDatabaseHelper 类继承自 SQLiteOpenHelper。代码如下:

class MyDatabaseHelper(
    private val context: Context,
    name: String,
    version: Int,
) : SQLiteOpenHelper(context, name, null, version) {

    // 定义 Book 表的建表语句
    private val createBook =
        """
        create table Book (
            id integer primary key autoincrement,
            author text,
            price real,
            pages integer,
            name text
        )
        """.trimIndent()

    override fun onCreate(db: SQLiteDatabase?) {
        db?.let {
            // 执行 SQL 语句
            it.execSQL(createBook)
        }
    }

    override fun onUpgrade(db: SQLiteDatabase?, oldVersion: Int, newVersion: Int) {
        
    }
}

其中定义建表语句时,我们用到了 """,这是用于定义多行字符串的语法。

然后给布局中添加一个按钮,用于触发数据库的创建。activity_main.xml 文件中的代码如下:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical">

    <Button
        android:id="@+id/createDatabase"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="Create Database" />

</LinearLayout>

MainActivity 中的代码如下:

class MainActivity : AppCompatActivity() {
    
    private lateinit var binding : ActivityMainBinding
    
    // 创建 MyDatabaseHelper 对象,指定版本号为 1
    private val dbHelper = MyDatabaseHelper(this, "BookStore.db", 1)
    
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)
        
        binding.createDatabase.setOnClickListener {
            // 启动一个 IO 线程的协程来执行数据库操作
            lifecycleScope.launch(Dispatchers.IO) {
                // 这是一个耗时操作,我们放在后台线程中完成
                dbHelper.writableDatabase
            }
        }
        
    }
}

在调用 getWritableDatabase() 方法时,如果是首次创建数据库,会触发 onCreate() 方法的执行。在 onCreate() 方法中,我们完成了 Book 数据表的创建操作。只有在数据库已存在的情况下,并且传入了更高的版本号,onUpgrade() 方法才会被执行

现在运行程序,点击按钮,结果如图所示:

image.png

查看数据库

怎么验证?这时不能使用之前的 Device Explorer 了,因为在 databases 目录下的 BookStore.db 文件中,你只能隐约看到建表语句和一堆乱码:

image.png

我们可以使用 Android Studio 内置的 App Inspection 来查看,你可以在 View->Tool Windows 中找到。

你能在其中看到 BookStore.db 数据库,以及 Book 数据表。

image.png

升级数据库

当需要增加新表或为旧表增加新字段时,就需要升级数据库。

我们可以在 MyDatabaseHelperonUpgrade() 方法中升级数据库。

例如,我们来添加一张 Category 图书分类表。修改 MyDatabaseHelper ,在 onCreate() 方法中加入新的建表语句,并在 onUpgrade() 方法中实现升级逻辑。代码如下:

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
        )
        """.trimIndent()
        
    // 定义创建分类表的建表语句
    private val createCategory =
        """
        create table Category (
            id integer primary key autoincrement,
            category_name text,
            category_code integer
        )
        """.trimIndent()

    override fun onCreate(db: SQLiteDatabase?) {
        db?.let {
            it.execSQL(createBook)
            it.execSQL(createCategory) // 执行 SQL 语句 
        }
    }

    override fun onUpgrade(db: SQLiteDatabase?, oldVersion: Int, newVersion: Int) {
        // 删除原有的所有数据表,此方法只是为了演示,这样会导致数据清空
        db?.let {
            it.execSQL("drop table if exists Book")
            it.execSQL("drop table if exists Category")
        }
        // 通过 onCreate() 方法重新创建所有数据表
        onCreate(db)
    }
}

那什么时候 onUpgrade() 方法会被调用呢?

在创建 MyDatabaseHelper 对象时,传入的 version 号大于当前数据库的版本号,这时,就会被调用。

MainActivity 中,把创建 MyDatabaseHelper 对象时,传入的版本号由 1 变为 2,代码如下:


class MainActivity : AppCompatActivity() {
    
    private lateinit var binding : ActivityMainBinding
    
    // 修改版本号为 2 
    private val dbHelper = MyDatabaseHelper(this, "BookStore.db", 1)
    
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)
        
        binding.createDatabase.setOnClickListener {
            lifecycleScope.launch(Dispatchers.IO) {
                dbHelper.writableDatabase
            }
        }
        
    }
}

重新运行程序,并且点击 “Create Database” 的按钮,数据库就升级成功了,同样你可以通过 App Inspection 验证。

image.png

通过 API 完成增删改查

我们对数据库的操作无非就四种(CRUD):创建(Create)、查询(Retrieve)、更新(Update)、删除(Delete)。

SQLiteDatabase 对象提供了简便的 API 来完成这几种操作,注意:数据库操作都是 IO 耗时操作,我们需要放在后台线程中执行。

添加数据

我们通过 SQLiteDatabaseinsert() 方法来添加数据。方法原型为 insert(String table, String nullColumnHack, ContentValues values),其中:

  • table:表名

  • nullColumnHack:在 ContentValues 对象为空时,为某一列强制插入 NULL,一般填入 null 即可。

  • valuesContentValues 对象,存放要添加的数据。ContentValues 内部提供了一系列 put() 方法用来添加数据,只需指定列名和对应的数据即可。

我们往布局中添加一个按钮,用于触发往表中添加数据的逻辑,activity_main.xml 文件中的代码如下:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical">

    ...

    <Button
        android:id="@+id/addData"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="Add Data" />

</LinearLayout>

然后在 MainActivity 中,给按钮的点击事件中完成添加数据的逻辑,代码如下:

class MainActivity : AppCompatActivity() {

    private lateinit var binding : ActivityMainBinding

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)

        val dbHelper = MyDatabaseHelper(this, "BookStore.db", 2)
        ...


        binding.addData.setOnClickListener {
            // 获取 SQLiteDatabase 对象
            val db = dbHelper.writableDatabase
            // 插入一条数据
            val values1 = ContentValues().apply {
                put("name", "The Da Vinci Code")
                put("author", "Dan Brown")
                put("pages", 454)
                put("price", 16.96)
            }
            db.insert("Book", null, values1) 
            
            // 插入另一条数据
            val values2 = ContentValues().apply {
                put("name", "The Lost Symbol")
                put("author", "Dan Brown")
                put("pages", 510)
                put("price", 19.95)
            }
            db.insert("Book", null, values2)
        }

    }
}

重新运行程序,点击 “Add Data” 按钮,即可完成数据的添加。

image.png

在 App Inspection 中可以看到 Book 表中新增的两条数据,如图所示:

image.png

更新数据

使用 SQLiteDatabase 提供的 update(String table, ContentValues values, String whereClause, String[] whereArgs) 方法更新数据。

  • values:包含要更新的数据的 ContentValues 对象。

  • whereClausewhereArgs:用于约束更新的行,默认更新所有行。

例如,我们来降低某本书的售价。

先往布局中添加一个按钮,用于触发数据更新,activity_main.xml 文件中的代码如下:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical">

    ...

    <Button
        android:id="@+id/updateData"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="Update Data" />

</LinearLayout>

然后在 MainActivity 中完成数据更新的逻辑,代码如下:

class MainActivity : AppCompatActivity() {

    private lateinit var binding: ActivityMainBinding

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)

        val dbHelper = MyDatabaseHelper(this, "BookStore.db", 2)
        ...

        binding.updateData.setOnClickListener {
            val db = dbHelper.writableDatabase
            val values = ContentValues()
            values.put("price", 10.99)
            db.update("Book", values, "name = ?", arrayOf("The Da Vinci Code"))
        }

    }
}

我们将 The Da Vinci Code这本书修改后价格定为了 10.99。其中 ? 表示占位符,会由 whereArgs 参数来提供具体的值。

重新运行程序,点击 “Update Data” 按钮,可以看到书的价格已经变为了 10.99,如图所示:

image.png

删除数据

使用 SQLiteDatabase 提供的 delete(String table, String whereClause, String[] whereArgs) 方法删除数据,它的约束的用法和更新完全相同。

往布局里添加一个按钮,用于触发数据删除,activity_main.xml 文件中的代码如下:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical">

    ...

    <Button
        android:id="@+id/deleteData"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="Delete Data" />

</LinearLayout>

然后在删除按钮的点击事件中, 删除页数超过 500 的书,MainActivity 的代码如下:

class MainActivity : AppCompatActivity() {

    private lateinit var binding: ActivityMainBinding

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)

        val dbHelper = MyDatabaseHelper(this, "BookStore.db", 2)
        ...

        binding.deleteData.setOnClickListener {
            val db = dbHelper.writableDatabase
            db.delete("Book", "pages > ?", arrayOf("500"))
        }

    }
}

重新运行程序,点击 “Delete Data” 按钮,再次查看表中的数据,如图所示:

image.png

查询数据

查询数据是最重要,也最复杂的一种操作。

SQLiteDatabase 提供了 query() 方法来完成查询,它的参数特别多,可以看下表:

query() 方法的参数对应 SQL 中部分描述
tablefrom table_name指定查询的表名
columnsselect column1, column2指定查询的列名
selectionwhere column = value指定查询的约束条件
selectionArgs-为约束条件中的占位符提供具体的值
groupBygroup by column指定需要分组的列
havinghaving column = value对分组后的结果进一步约束
orderByorder by column1, column2指定查询结果的排序方式

query() 方法会返回一个 Cursor 对象,我们需要遍历它来获取数据。

往布局中添加一个按钮,用于查询数据,activity_main.xml 文件中的代码如下所示:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical">

    ...

    <Button
        android:id="@+id/queryData"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="Query Data" />

</LinearLayout>

然后在查询按钮的点击事件中,查询数据并且通过 Cursor 对象将查询结果输出在日志中,MainActivity 的代码如下:

class MainActivity : AppCompatActivity() {

    private lateinit var binding: ActivityMainBinding

    @SuppressLint("Range")
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)

        val dbHelper = MyDatabaseHelper(this, "BookStore.db", 2)
        ...

        binding.queryData.setOnClickListener {
            val db = dbHelper.writableDatabase
            // 查询 Book 表中所有的数据
            val cursor = db.query("Book", null, null, null, null, null, null)

            // 使用 use 来自动关闭 Cursor 对象,避免资源泄漏
            cursor.use { c ->
                if (c.moveToFirst()) {
                    do {
                        // 先获取列的索引,再通过索引获取值,这是更健壮的做法
                        val nameIndex = c.getColumnIndex("name")
                        val authorIndex = c.getColumnIndex("author")
                        val pagesIndex = c.getColumnIndex("pages")
                        val priceIndex = c.getColumnIndex("price")

                        // 确保列存在
                        if (nameIndex != -1) {
                            val name = c.getString(nameIndex)
                            Log.d("MainActivity", "book name is $name")
                        }
                        if (authorIndex != -1) {
                            val author = c.getString(authorIndex)
                            Log.d("MainActivity", "book author is $author")
                        }
                        if (pagesIndex != -1) {
                            val pages = c.getInt(pagesIndex)
                            Log.d("MainActivity", "book pages is $pages")
                        }
                        if (priceIndex != -1) {
                            val price = c.getDouble(priceIndex)
                            Log.d("MainActivity", "book price is $price")
                        }
                    } while (c.moveToNext())
                }
            }
        }

    }
}

getColumnIndex() 方法如果找不到对应的列,会返回 -1,如果直接将 -1 传入 getString() 等方法会导致程序崩溃。所以我们要对索引判断是否有效,才能去获取数据。

重新运行程序,点击 “Query Data” 按钮,可以看到日志信息打印情况如图所示:

image.png

这只是最基础的查询,query() 方法的很多参数我们都还没用到。

使用 SQL 操作数据库

虽然 Android 给我们提供的 API 特别方便,但你也可以直接通过 SQL 语句来操作数据库。

例如:

val dbHelper = MyDatabaseHelper(this, "BookStore.db", 2)
val db = dbHelper.writableDatabase
// 插入数据
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)
cursor.close()

注意:查询数据是调用 rawQuery() 方法来完成的,并且该方法会返回一个 Cursor 对象。而其他操作调用的是 execSQL() 方法。