Kotlin中的数据存储

数据存储

1 持久化技术简介

数据持久化就是指将那些内存中的瞬时数据保存到存储设备中,保证即使在手机或计算机关机的情况下,这些数据仍然不会丢失。 保存在内存中的数据是处于瞬时状态的,而保存在存储设备中的数据是处于持久状态的。持久化技术提供了一种机制,可以让数据在瞬时状态和持久状态之间进行转换。

Android系统中主要提供了3种方式用于简单地实现数据持久化功能:文件存储、SharedPreferences存储以及数据库存储。

2 文件存储

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

2.1 将数据存储到文件中

Context类中提供了一个openFileOutput()方法,可以用于将数据存储到指定的文件中。这个方法接收两个参数:第一个参数是文件名,在文件创建的时候使用,注意这里指定的文件名不可以包含路径,因为所有的文件都默认存储到/data/data/<package name>/files/目录 下;第二个参数是文件的操作模式,主要有MODE_PRIVATEMODE_APPEND两种模式可选,默认是MODE_PRIVATE,表示当指定相同文件名的时候,所写入的内容将会覆盖原文件中的内容,而MODE_APPEND则表示如果该文件已存在,就往文件里面追加内容,不存在就创建新文件。

其实文件的操作模式本来还有另外两种:MODE_WORLD_READABLEMODE_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.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">

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

</LinearLayout>

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

其实现在你就可以运行一下程序了,界面上肯定会有一个文本输入框。然后在文本输入框中随 意输入点什么内容,再按下Back键,这时输入的内容肯定就已经丢失了,因为它只是瞬时数据,在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()方法把输入的内容存储到文件中,文件命名为datasave()方法中的代码和之前的示例基本相同,这里就不再做解释了。

现在重新运行一下程序,并在EditText中输入一些内容,然后按下Back键关闭程序,这时我们输入的内容就保存到文件中了。

那么如何才能证实数据确 实已经保存成功了呢?我们可以借助Device File Explorer工具查看一下。这个工具在Android Studio的右侧边栏当中,通常是在右下角的位置,如果你的右侧边栏中没有这个工具的话,也 可以使用快捷键Ctrl + Shift + A(Mac系统是command + shift + A)打开搜索功能,在搜索框中输入Device File Explorer即可找到这个工具。

2.2 从文件中读取数据

类似于将数据存储到文件中,Context类中还提供了一个openFileInput()方法,用于从文件中读取数据。这个方法要比openFileOutput()简单一些,它只接收一个参数,即要读取的文件名,然后系统会自动到/data/data/<package name>/files/目录下加载这个文件,并返 回一个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()方法读取文件中存储的文本内容,如果读到的内容不为空,就调用EditTextsetText()方法将内容填充到EditText 里,并调用setSelection()方法将输入光标移动到文本的末尾位置以便继续输入,然后弹出 一句还原成功的提示。

3 SharedPreferences存储

不同于文件的存储方式,SharedPreferences是使用键值对的方式来存储数据的。也就是说, 当保存一条数据的时候,需要给这条数据提供一个对应的键,这样在读取数据的时候就可以通过这个键把相应的值取出来。 而且SharedPreferences还支持多种不同的数据类型存储,如果存储的数据类型是整型,那么读取出来的数据也是整型的;如果存储的数据是一个字符串,那么读取出来的数据仍然是字符串。

使用SharedPreferences进行数据持久化要比使用文件方便很多。

3.1 将数据存储到SharedPreferences

要想使用SharedPreferences存储数据,首先需要获取SharedPreferences对象。Android中主要提供了以下两种方法用于得到SharedPreferences对象。

3.1.1 Context类中的getSharedPreferences()方法

**此方法接收两个参数:第一个参数用于指定SharedPreferences文件的名称,如果指定的文件不存在则会创建一个,SharedPreferences文件都是存放在/data/data/<package name>/shared_prefs/目录下的;第二个参数用于指定操作模式,目前只有默认的 MODE_PRIVATE这一种模式可选,它和直接传入0的效果是相同的,表示只有当前的应用程序才可以对这个SharedPreferences文件进行读写。**其他几种操作模式均已被废弃, MODE_WORLD_READABLEMODE_WORLD_WRITEABLE这两种模式是在Android 4.2版本中被废弃的,MODE_MULTI_PROCESS模式是在Android 6.0版本中被废弃的。

3.1.2 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文件。

3.2 从SharedPreferences中读取数据

SharedPreferences对象中提供了一系列的get方法,用于读取存储的数据,每种get方法都对应了SharedPreferences.Editor中的一种put方法,比如读取一个布尔型数据就使用getBoolean()方法,读取一个字符串就使用getString()方法。这些get方法都接收两个参数:第一个参数是键,传入存储数据时使用的键就可以得到相应的值了;第二个参数是默认值,即表示当传入的键找不到对应的值时会以什么样的默认值进行返回。

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" />

    <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将这些值打印出来。

3.3 实现记住密码功能

登录界面的布局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,然后把accountpassword对应的值都存入SharedPreferences文件中并提交;如果没有被选中,就简单地调用一下clear()方法, 将SharedPreferences文件中的数据全部清除掉。

当用户选中了记住密码复选框,并成功登录一次之后,remember_password键对应的值就是true了,这个时候如果重新启动登录界面,就会从SharedPreferences文件中将保存的账号和密码都读取出来,并填充到文本输入框中,然后把记住密码复选框选中,这样就完成记住密码的功能了。

不过需要注意,这里实现的记住密码功能仍然只是个简单的示例,不能在实际的项目中直接使用。因为将密码以明文的形式存储在SharedPreferences文件中是非常不安全的,很容易被别人盗取,因此在正式的项目里必须结合一定的加密算法对密码进行保护才行。

4 SQLite数据库存储

SQLite是一款轻量级的关系型数据库,它的运算速度非常快,占用资源很少,通常只需要几百KB的内存就足够了,因而特别适合在移动设备上使用。SQLite不仅支持标准的SQL语法,还遵循了数据库的ACID事务。SQLite又比一般的数据库要简单得多,它甚至不用设置用户名和密码就可以使用。Android正是把这个功能极为强大的数据库嵌入到了系统当中。

文件存储和SharedPreferences存储毕竟只适用于保存一些简单的数据和键值对,当需要存储大量复杂的关系型数据的时候,你就会发现以上两种存储方式很难应付得 了。比如我们手机的短信程序中可能会有很多个会话,每个会话中又包含了很多条信息内容, 并且大部分会话还可能各自对应了通讯录中的某个联系人。很难想象如何用文件或者SharedPreferences来存储这些数据量大、结构性复杂的数据。

4.1 创建数据库

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()方法也会得到执行,所以通常会在这里处理一些创建表的逻辑。

首先新建一个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 keyid列设为主键,并用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

这个目录下还存在另外一个BookStore.db-journal文件,这是一个为了让数据库能够支持事务而产生的临时日志文件,通常情况下这个文件的大小是0字节,我们可以暂时不用管它。

现在对着BookStore.db文件右击→Save As,将它从模拟器导出到计算机的任意位置。然后观察Android Studio的工具栏,现在应该多出了一个DB Navigator工具,这就是我们刚刚安装的插件了,选择DataBase Browser。为了打开刚刚导出的数据库文件,我们需要点击这个工具左上角的加号按钮,并选择SQLite选 项,如图所示:

SQLite

然后在弹出窗口的Database配置中选择我们刚才导出的BookStore.db文件,如图所示:

打开数据库

点击OK完成配置,这个时候DB Browser中就会显示出BookStore.db数据库里所有的内容了,如图所示:

数据库

可以看到,BookStore.db数据库中确实存在了一张Book表,并且Book表中的列也和我们前面使用的建表语句完全匹配,由此证明BookStore.db数据库和Book表确实已经创建成功了。

4.2 升级数据库

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文件了,如图所示:

Book&Category

可以看到,Category表已经创建成功了,说明升级功能的确起到了作用。

4.3 添加数据

我们可以对数据进行的操作无非有4种,即CRUD。其中C代表添加(create)R代表查询(retrieve)U代表更新(update)D代表删除(delete)。每一种操作都对应了一种SQL命令,在SQL语言中,添加数据时使用insert,查询数据时使用select,更新数据时使用update,删除数据时使用delete。对于SQL语言,Android提供了一系列的辅助性方法,在Android中即使不用编写SQL语句,也能轻松完成所有的CRUD操作。

调用SQLiteOpenHelpergetReadableDatabase()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表,会弹出一个如图所示的窗口:

Filter

这个窗口是用来设置查询条件的,这里我们不需要设置任何查询条件,直接点击窗口下方的No Filter按钮即可,然后就可以看到如图所示的数据了:

数据

4.4 更新数据

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。然后调用了SQLiteDatabaseupdate()方法执行具体的更新操作,可以看到,这里使用了第三、第四个参数来指定具体更新 哪几行。第三个参数对应的是SQL语句的where部分,表示更新所有name等于?的行,而?是一 个占位符,可以通过第四个参数提供的一个字符串数组为第三个参数中的每个占位符指定相应 的内容,arrayOf()方法是Kotlin提供的一种用于便捷创建数组的内置方法。因此上述代码想表达的意图就是将The Da Vinci Code这本书的价格改成10.99

4.5 删除数据

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页的书。

4.6 查询数据

SQL的全称是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()
}

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

4.7 使用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)

可以看到,除了查询数据的时候调用的是SQLiteDatabaserawQuery()方法,其他操作都是调用的execSQL()方法。

5 SQLite数据库实践

5.1 使用事务

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中事务的标准用法,首先调用SQLiteDatabasebeginTransaction()方法开启一个事务,然后在一个异常捕获的代码块中执行具体的数据库操作,当所有的操作都完成之后,调用setTransactionSuccessful()表示事务已经执行成功了,最后在finally代码块中调用endTransaction()结束事务。注意观察,我们在删除旧数据的操作完成后手动抛出了一个NullPointerException,这样添加新数据的代码就执行不到了。不过由于事务的存在,中途出现异常会导致事务的失败,此时旧数据应该是删除不掉的。

现在运行一下程序并点击Replace Data按钮,然后点击Query Data按钮。你会发现,Book表中存在的还是之前的旧数据,说明我们的事务确实生效了。然后将手动抛出异常的那行代码删除并重新运行程序,此时点击一下Replace Data按钮,就会将Book表中的数据替换 成新数据了,你可以再使用Query Data按钮来验证一次。

5.2 升级数据库的最佳写法

直接使用升级版本号方法升级数据库的方式是非常粗暴的,为了保证数据库中的表是最新的, 我们只是简单地在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。相比于传统的数据库APIRoom的用法要更加复杂一些,但是却更加科学和规范,也更加符合现代高质量APP的开发标准。

评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值