LJ的Blog

学海无涯苦做舟

0%

ObjectBox填坑

前言

最近准备填一些以前开的坑,当然不能只填坑而没有收获,于是准备做一哈数据库缓存,左挑右选,最终选择了 ObjectBox,作者同样也是 greenDao 和 EventBus 的开发者,质量值得信赖。本文会介绍关于 ObjectBox 的基本使用和我在使用中碰到的一些问题,加上一些我在做缓存时候的思路。

引入

能力比较强的同学,直接放上官方文档:https://docs.objectbox.io/,首先在项目最外层 build.gradle 文件中加入:

1
2
3
4
5
6
7
8
9
10
11
buildscript {
ext.objectboxVersion = '2.2.0'
respositories {
jcenter()
}
dependencies {
// Android Gradle Plugin 3.0.0 or later supported
classpath 'com.android.tools.build:gradle:3.2.1'
classpath "io.objectbox:objectbox-gradle-plugin:$objectboxVersion"
}
}

在 app 模块的 gradle 文件中加入:

1
apply plugin: 'io.objectbox'

如果你和我一样也用 Kotlin,那也要加入:

1
apply plugin: 'kotlin-kapt'

这里简单解释一下,apt 全称是 annotation processing tool 即注解处理工具,而 kapt 也就是 kotlin 的注解处理工具了。注解处理工具主要用来在编译时获取被注解的类、方法等信息,生成所需代码,ButterKnife、EventBus 等框架都使用这种方式。这样 ObjectBox 的引入就完成了。

简单的封装和使用

首先介绍一下实体类的创建,Java 和 Kotlin 的都会说

1
2
3
4
@Entity
public class ExampleEntity {
@Id public long id;
}

使用 @Entity 注解实体类, @Id public long id,id 这个属性是固定的,一定要有,在生成对象的时候由 ObjectBox 为其赋值,这是一个自增的 long 类型的 id,在同一类型的实体类插入的时候会根据 id 决定是插入还是更新。写到这你可能就会有疑惑了,如果除了 id 其他的值都一样,那也会插入?答案是肯定的,如何解决放到我们稍微了解用法之后去探讨。继续说一下 Kotlin 实体类的创建:

1
2
@Entity
data class ExampleEntity(@Id var id: Long = 0)

data class 会有带有所有属性的构造函数,如果这个构造函数里的所有参数没有一个被 ObjectBox 注解的,那么 ObjectBox 会在取出数据将其映射为对象的时候报错:

1
io.objectbox.exception.DbException: Entity is expected to have a no-arg constructor: Data

意思是说实体被期望有一个无参的构造函数,但实际上你只要把被注解的属性放在他的构造函数里他也能正确的处理。

写完之后编译一下,让我们看看如何增删查改:

首先我们要构建一个 BoxStore 对象,然后用其进行后续操作:

1
2
3
4
5
6
7
8
9
10
val data = ExampleEntity()
// 构建 BoxStore 对象
val boxStore = MyObjectBox.builder().androidContext(context).build()
val exampleStore = boxStore.boxFor(ExampleEntity::class.java)
// 增(或者更新)
exampleStore.put(data)
// 删
exampleStore.remove(data)
// 查
exampleStore.query()

可以看到操作比较简单,但是需要注意的是 put(插入或者更新)运行了隐含的事务,简单的理解就是开销比较大,所以循环 put 这种操作是不推荐的,那么在需要循环时,该咋做呢?

1
2
3
4
5
6
7
8
9
10
11
val datas = mutableListOf<ExampleEntity>()
datas.add(ExampleEntity())
datas.add(ExampleEntity())
datas.add(ExampleEntity())
// 是的,有这个重载,可以直接 put 集合
exampleStore.put(datas)
// 同样的,如果循环改数据,不要改完一个提交一个,一起提交比较高效
for(d in datas) {
// d.xx = xx
}
exampleStore.put(datas)

当然不可能所有的情况都可以用上面的方式解决,比如有时我不先 put 可能会影响我下一次查改的正确性,那么可使用 ObjectBox 提供的 runInTx() 方法,这是一个同步的方法,ObjectBox 也提供了异步的 runInTxAsync() 方法。

接下来聊回之前的问题,如何解决只有 id 不一样,但是其他数据都一样的问题,实际上这是很常见的,比如你从接口获取数据,原生数据可能没有 id,又或者有唯一 id 但是并不是 long 类型而是 String 类型。那么你第一次请求,将数据存入数据库,第二次请求如果返回同样的数据,但是 ObjectBox 的 id 是自增的,也就是说逻辑上是同样的数据,但是会插入两次,这显然不是我们希望看到的。解决这种问题的方法有两种:

  • 自己指定 id,但是要保证唯一性
  • 在插入之前根据其他的唯一标志查询是否已经存在,保证不重复插入

这里第一种方法经过我的测试,无法适应所有情况,究其原因是因为 long 类型的位数限制(当然也可能是我没找到正确的处理算法),这里用以下的例子说明:

1
2
3
4
5
6
7
8
9
10
11
String s = "5aff4645421aa95f55cab5da";
buffer = ByteBuffer.allocate(s.length());
buffer.put(s.getBytes(), 0, s.getBytes().length);
buffer.flip();
System.out.println(buffer.getLong());

String s2 = "5aff4645421aa95f55cab5dc";
ByteBuffer buffer2 = ByteBuffer.allocate(s2.length());
buffer2.put(s2.getBytes(), 0, s2.getBytes().length);
buffer2.flip();
System.out.println(buffer2.getLong());

最后的输出结果是:

3846468145899779125
3846468145899779125

都一样?实际上是因为 long 类型是 64 位,最大值是 9223372036854775807,也就是一个 19 位的值,这里是最后的 a 和 c 不同,是被截掉的后面不同,但是这就无法保证 id 的唯一性了,如果要转换的 String 没那么长,还是可以一用的。那么就只能采用第二种方案了,伪代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
boxStore.runInTxAsync({
val dataStore = boxStore.boxFor(Data::class.java)
for (data in datas) {
val d = dataStore.query().equal(Data_._id, data._id).build().findFirst()
d?.let {
data.id = it.id
}
}

dataStore.put(datas)
}, { _, _ ->

})

这里其实是没必要用 runInTxAsync 的,但是偷个懒,先这么写了,Data_ 是 Object 自动生成的代码,包含了可以用于查询的字段,我这里的数据都包含了 _id 这个属性,这是唯一的,所以在插入之前我会首先根据 _id 查询,如果查到了,我会将对象中的 id 属性更新为查询到的 id,再将之插入(事实上是更新)。后面一个大括号主要是用来处理异常的,暂时啥都没干。关于 ObjectBox 的简介暂时就到这了,想了解更多,可以自行查阅资料。接下来说一下我关于结合 ObjectBox 和 接口请求 的思考和实现。

用 ObjectBox 实现缓存

首先要弄明白缓存数据的意义在哪,像图片缓存,可以提高加载效率和减少流量消耗。那么数据缓存对于 Android 客户端的意义在哪呢?我个人觉得还是在于提升体验,一些已经请求过的数据,在断网的时候为啥就看不到了?当然了,做缓存其实对于相当依赖网络的应用来说提升体验的幅度有限,而且也会提升开发的复杂度,但是这并不影响我们去学习和思考如何实现数据的缓存。

那么该如何实现缓存呢?首先要想明白我们想要实现的效果是怎样的,这里并不是真实的项目需求,只是我填坑的时候自己想要的,但是各位可以借鉴一下:我想要实现的效果就是优先加载从接口获取的数据,因为数据可能有更新,接口优先,如果接口数据获取失败,那么加载数据库的数据,而且我希望这是一个线性的过程,即:

1544982647050

当然,这种实现方式在弱网的环境下,是否加载缓存取决于 timeout 的时间,如果 timeout 时间设置的比较长的话,那效果其实也比较差,不过还是那句话,这不妨碍我去了解和思考。根据流程图,我们首先要弄清楚的是接口请求的问题,现在应用的请求一般用的都是 okhttp + retrofit 那一套,我也不例外,正常的流程是请求接口,获取数据,我希望在异常的时候从数据库加载数据,那么要弄清楚的就是在请求接口时会发生哪些异常了。我们平时使用的 Okhttp 到真正请求实际上是 Call 接口中的方法,我比较爱用同步的 execute() 方法,看一下他的注释:

1
2
3
4
5
6
7
/**
* Synchronously send the request and return its response.
*
* @throws IOException if a problem occurred talking to the server.
* @throws RuntimeException (and subclasses) if an unexpected error occurs creating the request
* or decoding the response.
*/

看描述会抛出 IOException 和 RuntimeException,那么在之后的异常处理中就很好解决了,我使用了 Kotlin 的协程,如果你不熟悉协程和 Kotlin 的高阶函数也不要紧,我会尽量解释代码的意思:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
fun <T> gankService(
context: Context? = null,
doSomethingInThread: (Response<GankData<T>>) -> Unit = {},
request: GankService.() -> Call<GankData<T>>
) = bg<Response<GankData<T>>> {
val call = request(GankService)
try {
val res = call.execute()
doSomethingInThread(res)
return@bg res
} catch (e: Exception) {
XLog.i("http", e.toString())
val error = when (e) {
is IOException -> Response.error<GankData<T>>(GankService.TIMEOUT_ERROR, ResponseBody.create(null, "IOException 可能是读写超时"))
is UnknownHostException -> Response.error<GankData<T>>(GankService.NETWORK_ERROR, ResponseBody.create(null, "UnknownHostException 可能是网络断开"))
else -> Response.error<GankData<T>>(1001, ResponseBody.create(null, "未处理的错误:$e"))
}

doSomethingInThread(error)
return@bg error
}
}

这里定义了一个高阶函数,前两个参数是有默认值,可以不穿的,最后一个是给 GankService 扩展了一个返回 Call<GankData> 类型的函数,这个 GankService 可以理解为我们平时使用的 retrofit.create(ApiService::class.java),而 bg<Response<GankData>> 则表明新开了一个协程,返回类型为 Response<GankData>,代码第一行拿到了 Call<GankData>,之后则是调用 execute() 来调用接口,doSomethingInThread,主要是为了如果有啥耗时的操作也想在这个线程里干,就传入这个参数,之后会在线程中调用,当然了,项目里前两个参数都没用上,可以忽略。如果执行成功,则会返回接口数据,如果失败,则会被 try {} catch() 捕获到异常,然后返回错误的 Response(此时 GankData 实际上是 null),在之后的代码里我则可以根据 Response 来先判断接口是否调用成功,这里关于异常处理暂时比较粗糙,因为实际上后面暂时也没针对不同的异常做出不同的处理。

OK,关于调用接口的处理到这里告一段落,接下来是异常从数据库读取数据,关于数据的存和读的代码一并放上:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
class DataManager {
lateinit var boxStore: BoxStore
val storeMap = HashMap<String, Box<Any>>()

fun initStore() {
boxStore = MyObjectBox.builder().androidContext(Global.context).build()
}

fun <T> getBoxFor(clz: Class<T>): Box<T> {
return boxStore.boxFor(clz)
}

fun addOrUpdateData(datas: List<Data>) {
boxStore.runInTxAsync({
val dataStore = boxStore.boxFor(Data::class.java)
for (data in datas) {
val d = dataStore.query().equal(Data_._id, data._id).build().findFirst()
d?.let {
data.id = it.id
}
}

dataStore.put(datas)
}, { _, _ ->

})
}

/**
* 查询 [start] 到 [end] 之间的数据,左边界 [start] 包含在内,右边界 [end] 不包含在内
*/
fun queryData(start: Int, end: Int): List<Data> {
val q = query(Data::class.java).build()
if (start > q.count()) return mutableListOf()
val result = q.find()
if (end > result.size) return result.subList(start, result.size)
return result.subList(start, end)
}

private fun <T> query(clz: Class<T>) = boxStore.boxFor(clz).query()
}

这里的代码比较简单,不多做解释了,接下来是代码的整合,写出来比我想象的要简单很多:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
object GankDataStore {
private val dataManager = DataManager()

fun initStore() {
dataManager.initStore()
}

suspend fun requestWelfareData(pageIndex: Int): List<Data>? {
gankService { getWelfare(pageIndex) }.await().apply {
return if (isSuccessful) {
this.body().results?.apply { dataManager.addOrUpdateData(this) }
} else {
val start = pageIndex * 10 - 10
dataManager.queryData(start, start + 10)
}
}

return mutableListOf()
}
}

主要解释一下 requestWelfareData 函数,前面加了suspend 表示挂起函数,也就是说执行这个函数的时候即使有异步的代码,但整体表现仍然是顺序的,我的表述可能不太对,那就描述一下现象。这里 gankService { getWelfare(pageIndex) } 相信大家还有印象,实际上就是前面写的全局函数,是用来请求接口的,很明显是一个异步的代码,但是执行到 await() 的时候,函数被挂起,直到 gankService { getWelfare(pageIndex) } 返回结果,之后的代码会在结果返回之后执行,如果是一般的异步代码,那可能表现就是后面的代码执行在结果返回之前。继续解释代码,在拿到结果时候(这里拿到的是 Response),首先判断接口调用是否成功,如果成功,再取出我们的数据类,这样做是很安全的,因为如果接口调用失败,直接取出数据会是空的。接口调用成功,取出数据并将之插入数据库,如果调用失败,则从数据库查找数据,如果这两个操作都失败了,最后会返回一个空的集合。到此整个流程结束了,代码我也测过,没啥问题。

后记

上面的实现实际上也没达到最终完成的程度,比如如果数据库中也没有数据了,这里没有处理,比如缓存管理等。但也不可能事先就把所有可能发生的问题都考虑到,而且在不同的阶段会面临不同的问题,但是作为思路写到这已经够了�