Android中的数据存储——持久化技术

说明: 本文是郭霖《第一行代码-第3版》的读书笔记

前面我们在登录界面输入的账号密码等数据,在程序关闭或者其他原因导致内存被回收后,就会丢失,这些数据成为瞬时数据,其存储在内存中。但对于某些关键数据我们想要保存起来,这就需要用到持久化技术了。

7.1 持久化技术简介

持久化技术就是将数据保存到存储设备中,持久化技术提供了一种机制,可以让数据在瞬时状态和持久状态间进行转换,Android中提供了三种方式用于简单地实现数据持久化功能:文件存储SharedPreferences存储以及数据库存储

7.2 文件存储

文件存储比较适合存储一些简单的文本数据或者二进制数据,如果想用文件存储的方式保存一套较为复杂的结构化数据,就需要定义一套格式规范,方便之后从文件中重新解析出来。

7.2.1 将数据存储到文件中

Context类中提供了openFileOut()方法,可以用于将数据存储到指定的文件中,这个方法接收两个参数,第一个是文件名,注意指定的文件名不能包含路径,因为文件默认保存到/data/data//files目录下,第二个参数是文件的操作模式,主要有MODE_PRIVATEMODE_APPEND,前者是覆盖、后者是追加。文件的操作模式还有MODE_WORLD_READABLEMODE_WORLD_WRITEABLE,这两种模式允许其他应用程序对我们程序中的文件进行读写,但容易引起安全漏洞,已被废弃。

openFileOut()方法返回的是一个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()
    }
}

use函数是Kotlin内置的扩展函数,保证在Lambda表达式执行完后自动将外层的流关闭。这样就不用写finally语句了,有点像Python里的with.

打开文件得到FileOutputStream对象
OutputStreamWriter
BufferedWriter
写入数据

下面编写一个完成的例子,首先在MainActivity中加入一个EditText,然后修改MainActivity的代码如下:

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
    }

    override fun onDestroy() {
        super.onDestroy()
        val inputText = editText.text.toString()
        save(inputText)
    }

    private fun save(inputText: String) {
        try {
            val output = openFileOutput("data", Context.MODE_PRIVATE)
            val writer = BufferedWriter(OutputStreamWriter(output))
            writer.use {
                it.write(inputText)
            }
        } catch (e: IOException) {
            e.printStackTrace()
        }
    }
}

onDestroy()中调用了save()方法,保证在Activity销毁的时候能够保存数据。可以借助Device File Explorer工具查看,也可以通过Ctrl+Shift+A打开搜索框搜索。

7.2.2 从文件中读取数据

Context类提供了一个openFileInput()方法,用于从文件中读取数据,只接收一个参数,即文件名,系统会自动从data/data//files目录下加载这个文件,并返回一个FileInputStream对象。

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
//        editText.hint = load()
        val inputText = load()
        if (inputText.isNotEmpty()) {
            editText.setText(inputText)
            editText.setSelection(inputText.length)
            Toast.makeText(this, "Restored successfully!", Toast.LENGTH_SHORT).show()
        }
    }

    private fun load(): String {
        val content = StringBuilder()
        try {
            // 读取文件中的数据
            val input = openFileInput("data")
            val reader = BufferedReader(InputStreamReader(input))
            reader.use {
                reader.forEachLine {
                    content.append(it)
                }
            }
         } catch (e: IOException) {
            e.printStackTrace()
        }
        return content.toString()
    }
}

7.3 SharedPreferences存储

SharedPreferences是使用键值对的方式来存储数据的,也就是说,当存储一条数据的时候,需要给这条数据提供对应的键,之后就可以通过键将值取出来SharedPreferences还支持不同类型的数据存储,如存储的时候是整型,则取出时也是整型。

7.3.1 将数据存储到SharedPreferences中

  1. Context类中的getSharedPreferences()方法

​ 此方法接收两个参数,第一个指定SharedPreferences文件名称,如果指定文件不存在则会创建一个。默认存储在data/data//shared_prefs目录下。第二个参数指定操作模式,目前只有默认的MODE_PRIVATE可选,,它与直接传0的效果是一样的,表示只有当前的应用程序才能对这个文件读写。其他的如MODE_WORLD_WRITEABLEMODE_WORLD_READABLEMODE_MULTI_PROCESS均已被废弃。

  1. Activity类中的getPreferences()方法

​ 这个方法和上面的很类似,但只接收一个文件操作模式,因为getPreferences()方法会自动将当前ACtivity的类名作为文件名。

得到一个SharedPreferences对象后,就可以开始向SharedPreferences文件中存储数据了。主要分为3步:

  1. 调用SharedPreferences对象的edit()方法获取一个SharedPreferences.Editor对象
  2. 然后向这个Editor对象中添加数据,格式是putBoolean()putString()等等
  3. 最后调用Editor的apply()方法将数据提交,从而完成数据存储操作。

这里先在activity_main.xml中添加了一个Button,然后修改MainActivity的代码如下:

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        val saveButton: Button = findViewById(R.id.saveButton)
        saveButton.setOnClickListener {
            val editor = getSharedPreferences("data", Context.MODE_PRIVATE).edit()
            editor.putString("name", "Tom")
            editor.putInt("age", 18)
            editor.putBoolean("married", false)
            editor.apply()
            Toast.makeText(this, "Save Data Successfully", Toast.LENGTH_SHORT).show()
        }
    }
}

点击Save Data按钮,会在默认目录下生成一个data.xml文件,与文件存储生成的data文件不同。

7.3.2 从SharedPreferences中读取数据

在activity_main中加上一个Restore Data的按钮,点击按钮可以打印出存储的数据。

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

//        val restoreButton: Button = findViewById(R.id.restoreButton)
        val restoreButton = findViewById<Button>(R.id.restoreButton)
        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", true)
            Log.d("MainActivity", "name is $name")
            Log.d("MainActivity", "age is $age")
            Log.d("MainActivity", "married is $married")
        }
    }
}

读取数据的方法很简单,首先要构造一个SharedPreferences对象,然后调用诸如getInt()getString()方法,这些方法接收两个参数,第一个是键,第二个是默认值,如果没找到这个键将会返回这个默认值。

7.3.3 实现记住密码功能

之前的BroadcastReceiver的最佳实践里,有一个账号和密码的登陆界面,现在我们想记住密码。修改登陆界面,加入一个复选框CheckBox,用户可以通过点击的方式选择是否记住密码。

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

	...
    <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:textSize="18sp"
            android:text="Remember Password"/>
        
    </LinearLayout>

    <Button
        android:id="@+id/login"
        android:layout_width="200dp"
        android:layout_height="60dp"
        android:layout_gravity="center_horizontal"
        android:text="Login"/>

</LinearLayout>

修改LoginActivity的代码:

class LoginActivity:BaseActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_login)

        //检查是否记住密码
        val prefs = getSharedPreferences("data", Context.MODE_PRIVATE)
        val isRemember = prefs.getBoolean("isRemember", 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 prefsEditor = getSharedPreferences("data", Context.MODE_PRIVATE).edit()
                if (rememberPass.isChecked) {
                    prefsEditor.putString("Account", account)
                    prefsEditor.putString("Password", password)
                    prefsEditor.putBoolean("isRemember", true)
                } else {
                    prefsEditor.clear()
                }
                prefsEditor.apply()

                val intent = Intent(this, MainActivity::class.java)
                startActivity(intent)
                finish()
            } else {
                Toast.makeText(this, "invalid account or password.", Toast.LENGTH_SHORT).show()
            }
        }
    }
}

首次登陆不会显示密码和账号,只有在输入正确的账户和密码且勾选记住密码的情况下,才会保存密码至文件,这样下次才能直接登陆。

但是现在发现之前写的最佳实践有Bug,强制下线返回到Login界面时,点Back键居然可以回到MainActivity。

7.4 SQLite数据库存储

Android系统内置了SQLite这款轻量的关系型数据库,占用资源少,通常只需要几百KB的内存就够了。

7.4.1 创建数据库

Android专门提供了一个SQLiteOpenHelper的帮助类用于管理数据库。SQLiteOpenHelper是一个抽象类,我们需要去继承它,且必须重写两个抽象方法:onCreate()onUpgrate()。在这两个方法中可以实现创建和升级数据库的逻辑。

SQLiteOpenHelper中有两个重要的实例方法:getReadableDatabase()getWritableDatabase()这两个方法都可以创建或打开一个现有的数据库,并返回一个可对数据库进行读写操作的对象。不同的是,当数据库不可写入的时候,getReadableDatabase()方法返回的对象将以只读的方式打开数据库,而getWritableDatabase()方法将会出现异常。

SQLiteOpenHelper类中有两个构造函数,这里一般使用参数少一点的那个即可:这个构造函数接收四个参数:第一个是context,第二个是数据库的名称,第三个参数允许我们在查询数据的时候返回一个自定义的Cursor,一般传入null即可,第四个参数表示当前数据库的版本号,可用于对数据库进行升级操作。

构建出SQLiteOpenHelper的实例后,再调用getReadableDatabase()getWritableDatabase()就可以创建数据库了,此时重写的onCreate()方法也能得到执行,所以通常在此处处理一些创建表的逻辑。数据库文件会放在/data/data//databases/目录下。

class MyDatabaseHelper(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(p0: SQLiteDatabase?) {
        p0?.execSQL(createBook)
        Toast.makeText(context, "Create succeeded", Toast.LENGTH_SHORT).show()
    }

    override fun onUpgrade(p0: SQLiteDatabase?, p1: Int, p2: Int) {
    }
}

这里把建表语句定义成了一个字符串变量,然后在onCreate()方法中调用了SQLiteDatabase的execSQL()方法来执行这条建表语句。并弹出一条Toast提示。

然后在布局文件中加入一个Button,用于创建数据库,最后修改MainActivity的代码如下:

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        //
        val dbHelper = MyDatabaseHelper(this, "BookStore.db", 1)
        val createDatabaseBtn: Button = findViewById(R.id.createDatabase)
        createDatabaseBtn.setOnClickListener {
            dbHelper.writableDatabase
        }
    }
}

首先构建一个MyDatabaseHelper对象,并且通过构造函数传入数据库的名称和版本号,在按钮的点击监听事件中调用了getWritableDatabase()方法,就创建成功了一张表。如果再点击按钮,由于此时已经存在了数据库,就不会重复创建了。

可以借助Database Navigator这个插件来查看数据库。在数据库文件的目录下还会存在一个journal文件,这是一个为了让数据库能够支持事务而产生的临时文件。

7.4.2 升级数据库

目前项目中已经有一张Book表来存放书的各种数据,但如果我们想再添加一张Category表用于记录图书的分类,该怎么做呢?

Category表的建表语句为:

create table Category (
	id integer primary key autoincrement,
	category_name text,
	category_code integer)

将其添加到MyDatabaseHelper中:

class MyDatabaseHelper(val context: Context, name: String, version: Int):
        SQLiteOpenHelper(context, name, null, version) {
	...
    private val createCategory = "create table Category (" +
            "id integer primary key autoincrement," +
            "category_name text," +
            "category_code integer)"
    
    override fun onCreate(p0: SQLiteDatabase?) {
        p0?.execSQL(createBook)
        p0?.execSQL(createCategory)
        Toast.makeText(context, "Create succeeded", Toast.LENGTH_SHORT).show()
    }

    override fun onUpgrade(p0: SQLiteDatabase?, p1: Int, p2: Int) {
    }
}

这时再点击按钮并不会执行onCreate()方法,也就无法将新添加的表创建成功了。因为此时BookStore.db已经存在了。

解决这个问题可以通过在onUpgrate()中加入适当的逻辑:

    override fun onUpgrade(p0: SQLiteDatabase?, p1: Int, p2: Int) {
        p0?.execSQL("drop table if exists Book")
        p0?.execSQL("drop table if exists Category")
        onCreate(p0)
    }

即在onUpgrate()方法内执行两条DROP语句,删除掉原来的表,再调用onCreate()创建新表。

接下来需要让onUpgrate()方法调用,只需要在SQLiteOpenHelper的构造函数中传入一个更高的版本号就可以了,比如将版本号指定为2,再次点击按钮就会弹出创建成功的提示。

7.4.3 添加数据

7.4.4 更新数据

7.4.5 删除数据

以上三种操作放在一起说。对数据无非四种操作:增删改查,借助getWritableDatabase()getReadableDatabase()返回的SQLiteDatabase对象就可以对数据进行CRUD操作了。

SQLiteDatabase中提供了一个insert()方法专门用于添加数据。接收三个参数:第一个是表名,第二个参数用于在为指定添加数据的情况下给某些可为空的表自动赋值为null,一般用不到,直接传null就可以了。第三个参数是一个ContentValues对象,提供了一系列的put()方法重载,用于向ContentValues中添加数据。

SQLiteDatabase实例提供了update()方法用于更新数据,接收四个参数:表名、ContentValues对象,第三个参数对应SQL语句中的where部分,表示更新字段中所有值为?的行,?是占位符,可通过第四个参数提供的字符串数组为?指定内容。

SQLiteDatabase实例提供了delete()方法用于删除数据,接收三个参数:表名、第二个参数和第三个参数用于约束删除某一行或者某几行的数据,不指定就会删除所有行。

在布局中加入添加数据、修改数据、和删除数据的Button,点击响应鼠标点击事件:

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        //
        val dbHelper = MyDatabaseHelper(this, "BookStore.db", 10)
        val createDatabaseBtn: Button = findViewById(R.id.createDatabase)
        createDatabaseBtn.setOnClickListener {
            dbHelper.writableDatabase
        }

//        contentValuesOf("" to "")

        val addDataBtn: Button = findViewById(R.id.addData)
        addDataBtn.setOnClickListener {
            val db = dbHelper.writableDatabase
            val values1 = ContentValues().apply {
                put("author", "Dan Brown")
                put("price", 16.96)
                put("pages", 454)
                put("name", "The Da Vinci Code")
            }
            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)
            Toast.makeText(this, "Insert Successful", Toast.LENGTH_SHORT).show()
        }

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

        val deleteDataBtn: Button = findViewById(R.id.deleteData)
        deleteDataBtn.setOnClickListener {
            val db = dbHelper.writableDatabase
            db.delete("Book", "pages > ?", arrayOf("500"))
        }
    }
}

在做这一节的时候遇到了一些坑:

  1. 项目路径中不能有中文:https://blog.csdn.net/weixin_42574892/article/details/106325430
  2. 之前用SQLite的时候,Device File Explorer会有延时,即使点Synchronize也不能及时刷新,要靠点击列表的设备才会刷新。Save as的时候要确认文件是最新版本
  3. 覆盖或者删除一个db文件的时候需要先断开连接,否则是删不掉的,注意看AS 右下角的提示有没有成功删除
  4. Google提供的KTX扩展库包含了ContentValues的简化用法。

7.4.6 查询数据

查询数据可以利用SQLiteDatabase提供的query()方法。其参数非常复杂,最短的也要接收七个参数:

Android中的数据存储——持久化技术_第1张图片

调用query()方法后会返回一个Cursor对象,数据可以通过这个游标取出。

        val queryDataBtn: Button = findViewById(R.id.queryData)
        queryDataBtn.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.getInt(cursor.getColumnIndex("pages"))
                    val price = cursor.getDouble(cursor.getColumnIndex("price"))
                    Log.d("MainActivity", "book name is $name")
                    Log.d("MainActivity", "book author is $author")
                    Log.d("MainActivity", "book pages is $pages")
                    Log.d("MainActivity", "book price is $price")
                } while (cursor.moveToNext())
            }

getColumnIndex()可以获取某一列在表中对应位置的索引,而cursor.getInt()接受的就是一个整形的索引。moveToFirst()获取的是第一行的位置。

7.4.7 使用SQL操作数据库

某些人更青睐于使用SQL语句而非上述的API来操作数据库,这也是可以的。除了查询使用的是rawQuery()语句,其他的都使用execSQL()方法。

7.5 SQLite数据库的最佳实践

7.5.1 使用事务

事务具有ACID特性,使得某组操作要么全部完成,要么就不执行。假设现在想删除Book表中的数据,并替换成新数据。我们无法接受删除完Book表后,突然出现异常,导致新数据没插上去,而且原来的数据也没有了。为了避免这种情况,可以使用事务:

        val replaceDataBtn: Button = findViewById(R.id.replaceData)
        replaceDataBtn.setOnClickListener {
            val db = dbHelper.writableDatabase
            db.beginTransaction() //开启事务
            try {
                db.delete("Book", null, null)
                if (true) {
//                    throw NullPointerException
                }
                val values = contentValuesOf("name" to "Game of Thrones", "author" to "George Martin", "pages" to 270, "price" 							to 20.85)
                db.insert("Book", null, values)
            } catch (e: Exception) {
                e.printStackTrace()
            } finally {
                db.endTransaction()
            }
            
        }

通过调用SQLiteDataBasebeginTransaction()endTransaction()方法来开启和关闭事务,事务失败后会回退,此时旧数据是删除不了的。

7.5.2 升级数据库的最佳写法

之前升级数据库的时候是在onUpdate()方法内直接删除掉存在的表,然后再调用onCreate()建立新表。这会导致原来的数据也被删除。更好的方法是在onUpdate()方法内加入判断:

override fun onUpgrade(p0: SQLiteDatabase?, p1: Int, p2: Int) {
    if (p1 <= 1) {
        p0?.execSQL(createCategory)
    }
    if (p1 <= 2) {
        p0?.execSQL("alter table Book add column category_id integer")
    }    
}

如果旧版本小于等于1,就只建立一张Category表,而原来的Book不动。如果是第一次建表,就会进入onCreate()方法建立两张表。‘

**注意:**每升级一个版本的时候都要写生判断语句,这是为了保证跨版本升级时,每一次改动都能被执行。

本节学的是Android平台传统的数据库操作方式。实际上Google专门推出了一个专用于Android平台的数据库框架-Room,用法虽更复杂但更科学规范,符合现代高质量APP开发标准。

你可能感兴趣的:(android,kotlin)