EventBus 和 GreenDao 的老东家 GreenRobot 推出的移动端数据库架构。
优点:
- 速度快,号称比目前主流数据库架构快 5-15 倍
- NoSQL,没有 rows、columns、SQL,是完全面向对象的 API
- 数据库升级做到完全自动
我对 NoSQL 最直接的认识就是高并发的 key-value 存储、元组结构不固定、面向对象的 API 等特征,从运行时的时间、空间的开销上;开发 / 维护的成本上。对比传统的关系型数据库有明显提升。
// 目前正式版本号为 V1.5.5 - 2018/04/17,以及 Beta 版的 V2.0.0。
2019/08/29,ObjectBox 已经更新到了 v2.3.4,新增了蛮多特性,最近尝试着用上并更新一下本笔记。
以下笔记整理于官方文档,更多可见于官方的 Demo。
目录如下:
- 依赖
- 实体类、注解
- 增删查改、事务
- Relations
- 本地浏览器调试
- 单元测试
- 配合 LiveData/Paging(未完待续)
依赖
根目录下 build.gradle
:
buildscript {
ext.objectboxVersion = '2.3.4'
repositories {
jcenter()
}
dependencies {
classpath 'com.android.tools.build:gradle:3.0.1'
classpath "io.objectbox:objectbox-gradle-plugin:$objectboxVersion"
}
}
app 下 build.gradle
:
apply plugin: 'com.android.application'
apply plugin: 'io.objectbox' // apply last
如果项目中用引入 Kotlin support,需要在上面额外添加:
apply plugin: 'kotlin-android' // if using Kotlin
apply plugin: 'kotlin-kapt' // if using Kotlin
至此 ObjectBox 插件将会自动添加其余的依赖,但假如插件无法添加所需的库和注释处理器到依赖就需要手动添加:
//java only
dependencies {
// all below should be added automatically by the plugin
compile "io.objectbox:objectbox-android:$objectboxVersion"
annotationProcessor "io.objectbox:objectbox-processor:$objectboxVersion"
}
****************************************************************************
//kotiln super
dependencies {
// all below should be added automatically by the plugin
compile "io.objectbox:objectbox-android:$objectboxVersion"
kapt "io.objectbox:objectbox-processor:$objectboxVersion"
// some useful Kotlin extension functions
compile "io.objectbox:objectbox-kotlin:$objectboxVersion"
}
初始化
官方推荐在 Application 中初始化 ObjectBox 的实例:
private static BoxStore mBoxStore;
@Override
public void onCreate() {
super.onCreate();
mBoxStore = MyObjectBox.builder().androidContext(this).build();
}
public BoxStore getBoxStore(){
return mBoxStore;
}
不要忘了在 AndroidManifest 引用自定义的 Application,然后在代码中获取:
notesBox = ((App) getApplication()).getBoxStore().boxFor(TestObjectBoxBean.class);
有一点要提一下, 如果是第一次引入 ObjectBox,这里的 MyObjectBox
是找不到的,创建了对应的实体类后 Build > Make project
或者 Rebuild Project
才会出现。
数据模型 & 注解
和现在流行的架构一样,ObjectBox 的数据模型使用注解的方式定义:
@Entity
public class TestObjectBoxBean {
@Id(assignable = true)
long id;
@Index
String name;
@Transient
String uom;
@NameInDb("age")
String test;
}
注解 | 说明 |
---|---|
@Entity | 这个对象需要持久化。 |
@Id | 这个对象的主键。 |
@Index | 这个对象中的索引。对经常大量进行查询的字段创建索引,会提高你的查询性能。 |
@NameInDb | 有的时候数据库中的字段跟你的对象字段不匹配的时候,可以使用此注解。 |
@Transient | 如果你有某个字段不想被持久化,可以使用此注解。 |
@Relation | 做一对多,多对一的注解。 |
@Unique | 被标识的字段必须唯一。2.0+ 支持; |
- 关于构造函数
首先,ObjectBox 的实体类必需一个空参数的构造函数,在 kotlin 中使用 data class 记得为参数设置默认值(或者提供一个空参数的构造函数),否则会在运行时报错: Entity is expected to have a no-arg constructor
其次官网提示到,提供一个包括了全部属性的构造函数将会加快程序的运行,这一项是可选项。
- 关于属性
ObjectBox 在构建 Entity_
(Cursor classes) 时需要访问实体类的属性,两种方法:
1、要求属性至少标识为 package private
,而不能使 private
。
2、如果要标识为 private
(规范要求 / 习惯使然),则要求提供标准的 getters
,就是符合驼峰等命名规范的 get() 方法。
- 关于主键 @Id
需要注意的是,默认情况下 id 是被 ObjectBox 管理的一个自增 id,也就是说被 @Id
标注的字段不需要也不能手动设置,如果要手动管理应该用 @Id(assignable = true)
标注字段。
而且被标注为主键的字段应该为 long 型。(可以替换)
这里有一个实际开发中我常用的技巧,就是通过 Gson 的注解,去防止通过接口获取数据时 id 字段被错误的赋值。(这里使用的 Retrofit 网络请求框架)
当然这种做法只是我个人习惯,因为有些时候 api 返回值的实体类和数据库实体类分开也是有好处的,这里直接持久化 api 返回的实体类只是一个小需求也就没考虑太多。
@Entity
class Category {
@SerializedName("_id")
@Id
var id: Long = 0
@SerializedName("id")
var category_id: String? = null // 将 api 返回的 id 值解析到该字段
}
此外,id 值中 0
代表未被初始化、-1
是框架内部保留字段。
- 关于 @Index
目前 @Index 不能用来标识类型为 byte[]、float、double 的字段
@Index is currently not supported for byte[], float and double
索引的大致原理相信都懂一些,和 Map
而在 2.0 版本之前,ObjectBox 只能用 @Index 标识的字段的值做 key。
在 2.0 之后则可以指定为不同类型来优化索引的效率,比如 String
类型的字段使用 HASH
将会节省更多空间。
@Index(type = IndexType.HASH)
private String name;
可选值为:
-- 不指定 type: String 类型将默认指定为 HASH
,其他类型将被指定为 VALUE
;
-- VALUE: 使用具体的值做索引;
-- HASH: 使用 32 位的 hash 来构建索引。小概率会出现冲突但不影响效率(官方如是说);
-- HASH64: 使用 64 为的 hash 构建。自然会比 32 占用更多空间;
- 关于 @Unique
基于 @Index。
在存入过程中,如果被 @Unique 标识的字段重复则会抛出 UniqueViolationException
异常。
...
@Unique
@Index(type = IndexType.VALUE)
private String name;
...
try {
box.put(new User("Sam Flynn"));
} catch (UniqueViolationException e) {
// a User with that name already exists
}
增删查改 & 事务
TestObjectBoxBean bean = new TestObjectBoxBean();
...
//第一步获取 Box 实例
Box beanBox = ((BApplication) getApplication())
.getBoxStore().boxFor(TestObjectBoxBean.class);
//新增和修改,put 的参数可以是 list
beanBox.put(bean);
//删除 id 为 2 的数据
beanBox.remove(2);
//查询,名字为 T 开头或者 uom 为 kg 的数据
List item = beanBox.query()
.startsWith(TestObjectBoxBean_.name,"T")
.or().equal(TestObjectBoxBean_.uom,"kg")
.orderDesc(TestObjectBoxBean_.gid).build().find();
查询时,用到了生成类 TestObjectBoxBean_
通常是实体类加一个下划线。
使用 builder.equal()
进行设置匹配,调用 startWith()
设置查询条件,find()
可以用于分页。
实用的 API 还有一些:
//query.setParameter() 可以修改初始化时 equal() 设置的参数,所以我们可以复用同一个 Query 对象去查询
Query query = userBox.query().equal(User_.firstName, "").build();
List joes = query.setParameter(User_.firstName, "Joe").find();
List jakes = query.setParameter(User_.firstName, "Jake").find();
Query query = builder.build();
User joe = query.findFirst();//返回第一个结果或 null
User joe = query.findUnique();//返回唯一的结果,如果查询出多个结果将抛出异常
//偏移量和数据量的限制
List joes = query.find(/* offset by */ 10, /* limit to */ 5 /* results */);
- Query 的一些方法:
//property() 可以直接取出属性值而不需要获取实例
String[] emails = userBox.query().build().property(User_.email).findStrings();
//nullValue() 可以在返回值为 null 时赋一个默认值
String[] emails = userBox.query().build()
.property(User_.email).nullValue("unknown").findStrings();
//distinct() 可以去重
String[] names = userBox.query().build()
.property(User_.firstName).distinct().findStrings();
//distinct(StringOrder.CASE_SENSITIVE) 可以设置为大小写敏感,默认是不区分
//可以配合上述的 unique(),在返回值不唯一时抛出异常
//throws if not exactly one name
String[] names = userBox.query().build().equal(User_.isAdmin, true)
.property(User_.firstName).unique().findStrings();
//也可以添加一些筛选的条件
songBox.query().equal(Song_.bandId, bandId)
// Filter is performed on candidate objects
.filter((song) -> {
//return isKeep
return song.starCount * 2 > song.downloads;
})
- 官方有这样一个提示,假如需要插入或修改多条数据,可以这样做:
for(User user: allUsers) {
modify(user); // modifies properties of given user
box.put(user);
}
但这种做法可能会需要较多的时间、花费更多的性能,正确做法:
for(User user: allUsers) {
modify(user); // modifies properties of given user
}
box.put(allUsers);
- 事务
Box 实例下的 put 和 remove 的执行实际上已经是事务的。
除此之外显性的使用事务也是可以的,ObjectBox 提供了几个 api:
API | 说明 |
---|---|
runInTx | 在给定的 runnable 中运行的事务。 |
runInReadTx | 只读事务,不同于 runInTx,允许并发读取 |
runInTxAsync | 运行在一个单独的线程中执行,执行完成后,返回 callback。 |
callInTx | 与runInTx 相似,不同的是可以有返回值。 |
boxStore.runInTx(new Runnable() {
@Override
public void run() {
for(User user: allUsers) {
if(modify(user)) box.put(user);
else box.remove(user);
}
}
});
Relations
虽然是 NoSQL,但是实际生产中,表与表之前逻辑上的关系是常有的,Relations 必不可少。
下面的例子中,customer 与 order 是一对一关系,order 与 line 是一对多关系。
有几个需要注意的地方:
- 使用
lateinit
标识ToMany
/ToOne
,objectBox 插件会在编译阶段为两者初始化;
The ObjectBox Gradle plugin will transform your entity class (only supported for plain Java and Android projects) to do the proper initialization in constructors before your code is executed. Thus, even in your constructor code, you can just assume ToOne and ToMany/ List properties have been initialized and are ready for you to use;
- 再讲一遍,实体类要一个空参数的构造函数,在 kotlin 中使用 data class 记得为参数设置默认值,否则会在运行时报错:
Entity is expected to have a no-arg constructor
@Entity
class TestCustomer(
@Id var id: Long = 0,
var name: String? = null
)
@Entity
class TestOrder(@Id var id: Long = 0) {
lateinit var customer: ToOne
@Backlink(to = "order")
lateinit var lines: ToMany
}
@Entity
class TestOrderLine (@Id var id: Long = 0,
var info: String = "") {
lateinit var order: ToOne
}
...
fun exampleTest() {
val customer = TestCustomer(name = "customer")
val order = TestOrder()
// 简单的初始化数据
for (i in 0..5) {
order.lines.add(TestOrderLine(info = "index$i"))
}
// order.customer 是 objectBox 插件初始化的 ToOne 实例, 为 ToOne().target 赋值来关联两者
order.customer.target = customer
// 存入数据库
val orderId = store?.boxFor(TestOrder::class.java)?.put(order)
// 通过 id 获取 orde 实例
val getOrderById = store?.boxFor(TestOrder::class.java)?.get(orderId!!)
// 通过 order 获取 lines
val getLineByOrder = getOrderById?.lines
// 通过 order.target 获取 customer
val getCustomerByOrder = getOrderById?.customer?.target
// 通过 line 可以反过来获取到 order 对应的实例
val getOrderByLine = store?.boxFor(TestOrderLine :: class.java)?.all?.get(0)?.order
}
数据库升级
首先,在要修改的字段添加 @Uid
注解。
然后 Build -> Make Project,
此时就可以直接修改字段的名称。
Rx 监听
Query builder = beanBox.query().build();
builder.subscribe().on(AndroidScheduler.mainThread()).observer(new DataObserver>() {
@Override
public void onData(List testObjectBoxBeen) {
//the query is executed in the background
//once the query finishes the observer gets the result data
//once updated query results are in, they are propagated to the observer
}
});
//Kotlin
Query query = beanBox.query().equal(Task_.complete, false).build();
query.subscribe(subscriptions)
.on(AndroidScheduler.mainThread())
.observer(data -> updateUi(data));
需要注意的是,对查询出来的数据进行修改会触发查询,查询结果同样会回调到这里的观察者。
本地浏览器调试
- 添加依赖:
官方建议仅在 debug 版本依赖,所以做一下区分:
dependencies {
debugImplementation "io.objectbox:objectbox-android-objectbrowser:$objectboxVersion"
releaseImplementation "io.objectbox:objectbox-android:$objectboxVersion"
}
并且在 Application 初始化的过程中新增判断:
boxStore = MyObjectBox.builder().androidContext(this).build();
if (BuildConfig.DEBUG) {
boolean started = new AndroidObjectBrowser(boxStore).start(this);
Log.i("ObjectBrowser", "Started: " + started);
}
至此,运行程序后可以在控制台看到类似的 log:
I/ObjectBrowser: ObjectBrowser started: http://localhost:8090/index.html
I/ObjectBrowser: Command to forward ObjectBrowser to connected host: adb forward tcp:8090 tcp:8090
复制第二行的命令并在控制台执行,如果 adb 路径没有配置好可能会报错,这里不表;
adb forward tcp:8090 tcp:8090
然后就可以在 PC 的浏览器中访问第一行 log 中的链接 http://localhost:8090/index.html
查看。
单元测试
其实就是用 JUnit 做的单元测试,加上一个 ObjectBox 的依赖即可。
- 依赖:
dependencies {
// Required -- JUnit 4 framework
testImplementation 'junit:junit:4.12'
// Optional -- manually add native ObjectBox library to override auto-detection,根据自己的开发环境选用一个
testImplementation "io.objectbox:objectbox-linux:$objectboxVersion"
testImplementation "io.objectbox:objectbox-macos:$objectboxVersion"
testImplementation "io.objectbox:objectbox-windows:$objectboxVersion"
}
复用官方 demo 的 setUp()
和 tearDown()
,然后编写自己的测试用例即可。
需要注意的是,在 1.4.4 或者更老的版本中,ToOne/ToMany
属性可能会失效,原因如上述,ToOne/ToMany
属性是需要 ObjectBox 插件通过修改字节码的方式去初始化的。
package dh.com.underline.module.pay
import io.objectbox.BoxStore
import org.junit.After
import io.objectbox.DebugFlags
import dh.com.underline.entity.table.MyObjectBox
import dh.com.underline.entity.table.TestCustomer
import dh.com.underline.entity.table.TestOrder
import dh.com.underline.entity.table.TestOrderLine
import org.junit.Before
import org.junit.Test
import java.io.File
class NoteTest {
private val TEST_DIRECTORY = File("objectbox-example/test-db")
private var store: BoxStore? = null
@Before
@Throws(Exception::class)
fun setUp() {
// 初始化操作,构建数据库文件、添加 debug 标志
// delete database files before each test to start with a clean database
BoxStore.deleteAllFiles(TEST_DIRECTORY)
store = MyObjectBox.builder()
// add directory flag to change where ObjectBox puts its database files
.directory(TEST_DIRECTORY)
// optional: add debug flags for more detailed ObjectBox log output
.debugFlags(DebugFlags.LOG_QUERIES or DebugFlags.LOG_QUERY_PARAMETERS)
.build()
}
@After
@Throws(Exception::class)
fun tearDown() {
// 在结束后关闭数据库链接、删除数据库文件
if (store != null) {
store!!.close()
store = null
}
BoxStore.deleteAllFiles(TEST_DIRECTORY)
}
@Test
fun exampleTest() {
// 构建自己的测试用例
val customer = TestCustomer(name = "customer")
val order = TestOrder()
order.customer.target = customer
for (i in 0..5) {
order.lines.add(TestOrderLine(info = "index$i"))
}
val orderId = store?.boxFor(TestOrder::class.java)?.put(order)
// 通过 id 获取 orde 实例
val getOrderById = store?.boxFor(TestOrder::class.java)?.get(orderId!!)
// 通过 order 获取 lines
val getLineByOrder = getOrderById?.lines
// 通过 order.target 获取 customer
val getCustomerByOrder = getOrderById?.customer?.target
// 通过 line 可以反过来获取到 order 对应的实例
val getOrderByLine = store?.boxFor(TestOrderLine :: class.java)?.all?.get(0)?.order
}
}
以上,如有错误欢迎指出。