ObjectBox - Android NoSQL 数据库框架

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<key, value> 的一样是用空间换时间的理念。

而在 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<TestObjectBoxBean> beanBox = ((BApplication) getApplication())
        .getBoxStore().boxFor(TestObjectBoxBean.class);

//新增和修改,put 的参数可以是 list
beanBox.put(bean);

//删除 id 为 2 的数据
beanBox.remove(2);

//查询,名字为 T 开头或者 uom 为 kg 的数据
List<TestObjectBoxBean> 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<User> query = userBox.query().equal(User_.firstName, "").build();
List<User> joes = query.setParameter(User_.firstName, "Joe").find();
List<User> jakes = query.setParameter(User_.firstName, "Jake").find();

Query<User> query = builder.build();
User joe = query.findFirst();//返回第一个结果或 null
User joe = query.findUnique();//返回唯一的结果,如果查询出多个结果将抛出异常

//偏移量和数据量的限制
List<User> 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<TestCustomer>
    @Backlink(to = "order")
    lateinit var lines: ToMany<TestOrderLine>

}

@Entity
class TestOrderLine (@Id var id: Long = 0,
                     var info: String = "") {
    lateinit var order: ToOne<TestOrder>
}

...
    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<TestObjectBoxBean> builder = beanBox.query().build();
builder.subscribe().on(AndroidScheduler.mainThread()).observer(new DataObserver<List<TestObjectBoxBean>>() {
    @Override
    public void onData(List<TestObjectBoxBean> 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<Task> query = beanBox.query().equal(Task_.complete, false).build();
query.subscribe(subscriptions)
     .on(AndroidScheduler.mainThread())
     .observer(data -> updateUi(data));

需要注意的是,对查询出来的数据进行修改会触发查询,查询结果同样会回调到这里的观察者。


本地浏览器调试

来自 ObjectBox 官方
  • 添加依赖:
    官方建议仅在 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
    }
}




以上,如有错误欢迎指出。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,117评论 4 360
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 66,963评论 1 290
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 107,897评论 0 240
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,805评论 0 203
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,208评论 3 286
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,535评论 1 216
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,797评论 2 311
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,493评论 0 197
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,215评论 1 241
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,477评论 2 244
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,988评论 1 258
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,325评论 2 252
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,971评论 3 235
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,055评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,807评论 0 194
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,544评论 2 271
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,455评论 2 266

推荐阅读更多精彩内容