安卓开发中使用 kotlin Object 和 lazy 关键字以及 Room 踩坑记录

闲话

掐指一算,已经很久没有写过文章了。

大概就是懒了,也不像以前那么有表达欲了。

再者就是实在不知道写些什么,一些没深度的东西写了也只是没什么营养的"水文",最多就是当成自己的备忘录,未来忘了可以速查一下。

其实这篇文章也是一篇水文,只是之前完全没想到会在这种"小问题"上翻车,所以还是记录一下。

问题背景

在我司的项目中,有这么一个功能,可以多用户登录,每个登录用户的数据使用单独的 sqlite 文件储存,文件命名也很简单粗暴,就是 $userId.db

每次用户登录时都会打开对应的数据库文件。

最近在写一个新的功能模块,需要使用数据库储存业务数据,由于这是个新设备,所以公司里面只有一台测试机,我自己自测没问题后交付给测试的同事去测试。

测试的同事切换到他的账号后,发现业务数据没有保存成功。

因为很久之前这个项目的数据储存也出现过一次类似问题,所以这次我也自然而然的就往相同的方向去排查了。

至于之前是什么问题我们就暂且按下不表,先来看看当前的问题。

问题分析

首先来看看我们项目中的数据库创建方法:

kotlin 复制代码
abstract class TestDatabase: RoomDatabase() {
    abstract fun testDao(): TestDao

    companion object{
        private var instance : TestDatabase? = null

        @Synchronized
        fun getDatabase(): TestDatabase{
            if (instance == null) {
                val name = "test_${userPhone}.db"
                instance = Room.databaseBuilder(context, TestDatabase::class.java, name)
                    .fallbackToDestructiveMigration()
                    .build()
            }
            return instance!!
        }
        
        fun logout() {
            instance = null
        }
    }
}

如上节所说,我们会根据当前登录的用户切换使用的数据库文件。

然后在业务中使用时是这样获取 Dao 实例的:

kotlin 复制代码
private val testDao by lazy { TestDatabase.getDatabase().testDao() }

我们再明确一下问题,首次登录用户的数据可以正常写入,但是如果切换其他用户,那么数据就会无法写入。

按照常规思路,我先检查了写入数据的方法是否返回异常:

kotlin 复制代码
// ......
@Insert
fun insert(bean: TestBean): Long

// ......

val result = testDao.insert(bean)
Log.d("Test", "result: $result")

输出结果确实为插入成功 row Id。

接着我又在插入方法后立即查询数据,发现数据确实插入成功了。

kotlin 复制代码
// ......

@Query("SELECT * FROM TABLE_TEST ORDER by id")
fun getAll(): List<TestBean>
// ......
testDao.insert(bean)
val result = testDao.getAll()
Log.d("Test", "result: $result")

但是在 APP 中就是没有显示数据,给 APP 中获取数据的方法打断点发现确实没从数据库中拿到任何数据。

于是我使用 App Inspection 实时查看数据,发现虽然返回插入结果和插入后立即查询的结果都没问题,但是在 App Inspection 中并没有任何数据被插入!

此时我已经隐约觉得大概率是数据库文件错了,于是我的排查思路改为检查数据库实例是否在退出登录时被成功置为 null 。

经过一番排查,确定在 TestDatabase 中的 logout() 方法确实被调用了,并且 instance 被成功置为 null ,再次登录后也生成了新的数据库实例。

那究竟是怎么回事呢?

我再次使用 App Inspection 查看数据实时变动,果然,数据是被写入了上次登录的用户的数据库文件中。

这证实了我的猜想。

那么为什么第二个用户的数据被写入了第一个用户的数据库,但是第一个用户没有察觉呢?

说来也巧,这是因为我们的数据在储存时每条数据都会绑定一个业务 id,而每个用户的业务 id 都是不同的,所以虽然数据错误的存到了第一个用户的数据库中,但是实际取出数据时也不会去取这些错误的数据。

但是,究竟为什么数据会写错文件呢?可以确定数据库实例在切换用户时已经重新创建了,之前的业务也没有问题,唯独刚写的新业务出现了问题。

突然,灵光一闪。

哦对,我嫌弃之前同事写的代码"乱七八糟"的,所以就自己封装了一个新业务的数据库操作类:

kotlin 复制代码
Object TestUtils {
    private val testDao by lazy { TestDatabase.getDatabase().testDao()}
    
    // ......
    
    suspend fun insertData(bean: TestBean) {
        // ......
        testDao.insert(bean)
        // ......
    }
}

一看这个 testDao 初始化方法再加上这是个单例类,我恍然大悟。

Object 单例类在整个 APP 生命周期内只会有一个实例,而 by lazy 只会在第一次调用时初始化,其他时候调用的都是第一次初始化的结果。

也就是说,TestUtils 中的 testDao 实例在本次 APP 的生命周期中永远是同一个,无论怎么切换用户,数据被写入的永远是第一个用户的数据库文件。

那么问题又来了,为什么之前的业务没问题呢?

我们再来看看之前我所说的同事的"乱七八糟"的代码,每次操作数据库都是直接获取一个新的实例:

kotlin 复制代码
TestDatabase.getDatabase().testDao().insert(bean)

emmmm,一瞬间不知道是有意为之还是瞎猫碰到死耗子了。

总之提醒我了,by lazy 虽爽,可不要滥用哦。

另外一个问题

上文还提到,之前也遇到过一次类似的问题,那次的问题更加奇葩。

问题背景

我们的项目是一个运行在定制的安卓平板设备上的项目 ,这个设备没有电池供电,都是直接插适配器工作。

某天有客户反馈,他前一天完成了业务单据,并且提交到系统后,第二天打开软件,发现业务单据还在本地。

这个项目的操作逻辑是,业务单据的数据在提交之前都会保存到本地数据库中,当提交成功后会删除本地已保存的单据数据。

问题排查

按照常规思路,依次排查了单据是否提交成功,删除单据是否成功,删除数据逻辑是否存在问题等。

但是始终没有发现有任何问题,也复现不出来。

最后,通过检查日志,发现了一个奇怪的地方,客户提交完单据后,后面就没有任何日志了。

正常来说,日志中应该会有所有 Activity 的生命周期的打点记录以及启动退出 APP 的打点记录,

但是从现场客户的日志来看,单据提交成功后,后续就没有任何日志了。

只有一种可能,客户在完成单据提交后,直接拔掉了电源......

我按照这个操作步骤尝试了一下,还真复现了。

但是,即使直接拔掉了电源也不应该出现数据没删除的情况啊,从日志来看,数据删除操作是已经完成了之后才断电的,而且删除数据也没有爆出任何异常。

这时,我突然想到,之前 dump APP 的数据库文件时发现,如果只打开 .db 文件,那么数据是不完整的,同样的路径下往往还有一个同名的 .db-wal 文件,只有把这个文件 dump 出来一起打开,数据才是完整的。

我就搜索了一下这个文件,发现原来这个文件是数据库日志文件,在将数据修改写入 .db 文件时会先写入这个文件缓存。

也就是说,大概率是 APP 删除数据还没来得及写回 .db 文件就被断电了,所以重新上电后数据还是存在。

按照这个思路,我尝试了删除成功后延迟一段时间再断电,发现依旧无法解决。

我又尝试在删除成功后立即提交事务修改再断电,仍然无法解决。

查看了一下 Room 的默认日志模式,发现使用的是 AUTOMATIC 模式,按照文档:

text 复制代码
Let Room choose the journal mode. This is the default value when no explicit value is specified.
The actual value will be TRUNCATE when the device runs API Level lower than 16 or it is a low-RAM device. Otherwise, WRITE_AHEAD_LOGGING will be used.

显然在我的设备中,启用的是 WRITE_AHEAD_LOGGING 即 WAL 模式,在该模式时即使我删除数据后立即提交事务,也可能数据并没有写入文件,因为 sqlite 内部会按照配置调度异步写入,所以并不能保证提交事务后一定会写入文件。

为了解决这个问题,我们可以修改日志模式,例如我是改为了 TRUNCATE 模式,在该模式中不会创建 .wal 文件,而是会直接将数据写入 .db 文件中,并且虽然会生成用于回滚的日志(.db-journal),但是会在每次事务提交后就截断回滚日志,这样,即使我删除操作完成后立即断电再上电,数据库也不会被回滚:

kotlin 复制代码
Room.databaseBuilder(context, TestDatabase::class.java, name)
    .setJournalMode(JournalMode.TRUNCATE)
    .build()

总结

其实如果我们在写代码时能够静心学习原理,至少要明白实现机制而不是只做一个 API Caller 上面的问题排查起来就会简单的多了。

另外,有时候别总想着重构这重构那的,一些看似"乱七八糟"的老代码,说不定自有它的道理在。

相关推荐
xiaobin8899910 分钟前
PowerDesigner安装教程(附加安装包)PowerDesigner详细安装教程PowerDesigner 16.6 最新版安装教程
数据库·其他
程序员是干活的19 分钟前
Java EE前端技术编程脚本语言JavaScript
java·大数据·前端·数据库·人工智能
南囝coding42 分钟前
Coze 开源了!所有人都可以免费使用了
前端·后端·产品
CDwenhuohuo44 分钟前
滚动提示组件
java·前端·javascript
wei3872452321 小时前
集训总结2
java·数据库·mysql
说码解字1 小时前
Kotlin 内联函数
前端
PineappleCoder1 小时前
性能优化与状态管理:React的“加速器”与“指挥家”
前端·react.js
_一两风1 小时前
深入理解React中的虚拟DOM与Diff算法
前端
GoodTime1 小时前
CodeBuddy IDE深度体验:全球首个产设研一体AI工程师的真实使用报告
前端·后端·架构
前端的日常1 小时前
说说你对 React Hook的闭包陷阱的理解,有哪些解决方案?
前端