據(jù)庫(kù):Android開(kāi)發(fā)中的高性能對(duì)象持久化方案)
1. 項(xiàng)目概述為什么我們需要一個(gè)“快得飛起”的數(shù)據(jù)庫(kù)如果你在Android開(kāi)發(fā)中用過(guò)SQLite或者被Room的編譯時(shí)注解和樣板代碼搞得有點(diǎn)煩那你肯定對(duì)數(shù)據(jù)庫(kù)的性能和易用性有過(guò)那么一絲期待。我們總在尋找一個(gè)更優(yōu)解它最好能像直接操作對(duì)象一樣簡(jiǎn)單性能又要足夠強(qiáng)悍特別是在處理大量數(shù)據(jù)、復(fù)雜關(guān)系或者需要高頻讀寫的場(chǎng)景下SQLite那套基于SQL語(yǔ)句的CRUD操作有時(shí)確實(shí)會(huì)讓人覺(jué)得“差那么點(diǎn)意思”。今天要聊的ObjectBox就是一個(gè)試圖從根本上解決這些痛點(diǎn)的家伙。它自稱是一個(gè)“超高性能的面向?qū)ο髷?shù)據(jù)庫(kù)”在官方和社區(qū)的benchmark里其速度經(jīng)常是SQLite的數(shù)倍甚至數(shù)十倍而且號(hào)稱是真正的“零配置”用起來(lái)極其輕量。我第一次接觸ObjectBox是在一個(gè)需要實(shí)時(shí)記錄大量傳感器數(shù)據(jù)的項(xiàng)目里。當(dāng)時(shí)用Room每次批量插入上萬(wàn)條記錄時(shí)雖然用了事務(wù)但UI線程還是會(huì)感到輕微的卡頓更別提復(fù)雜的聯(lián)表查詢了。換上ObjectBox后最直觀的感受就是“順滑”——數(shù)據(jù)寫入像往本地List里add對(duì)象一樣快查詢更是幾乎無(wú)感。它不是一個(gè)ORM框架而是一個(gè)完整的數(shù)據(jù)庫(kù)引擎這意味著它的“快”是架構(gòu)層面的。對(duì)于移動(dòng)端開(kāi)發(fā)尤其是對(duì)性能敏感、追求極致用戶體驗(yàn)的應(yīng)用如IoT數(shù)據(jù)采集、即時(shí)通訊、高頻交易模擬、離線緩存等一個(gè)“快得飛起”的數(shù)據(jù)庫(kù)可能就是那個(gè)關(guān)鍵的勝負(fù)手。2. ObjectBox核心設(shè)計(jì)思路與架構(gòu)解析2.1 面向?qū)ο?vs. 關(guān)系型思維模式的根本轉(zhuǎn)變要理解ObjectBox為什么快首先要跳出關(guān)系型數(shù)據(jù)庫(kù)的思維定式。SQLite是典型的關(guān)系型數(shù)據(jù)庫(kù)它用二維表來(lái)存儲(chǔ)數(shù)據(jù)行和列結(jié)構(gòu)規(guī)整。而ObjectBox是一個(gè)面向?qū)ο髷?shù)據(jù)庫(kù)。它的核心設(shè)計(jì)理念是直接將你的Java/Kotlin對(duì)象持久化到磁盤對(duì)象之間的關(guān)系一對(duì)一、一對(duì)多、多對(duì)多也通過(guò)對(duì)象引用的方式直接映射而不是通過(guò)外鍵和JOIN操作。這帶來(lái)了幾個(gè)根本性的優(yōu)勢(shì)零阻抗失配你不需要在“對(duì)象模型”和“關(guān)系模型”之間做轉(zhuǎn)換。你的User類就是數(shù)據(jù)庫(kù)里的User實(shí)體它的ListOrder屬性直接對(duì)應(yīng)到多個(gè)Order對(duì)象。省去了編寫和維護(hù)大量的映射代碼如Room的Entity、Dao、Relation注解鏈也避免了因模型不一致導(dǎo)致的Bug。訪問(wèn)路徑優(yōu)化在關(guān)系型數(shù)據(jù)庫(kù)中查詢關(guān)聯(lián)數(shù)據(jù)需要執(zhí)行JOIN這涉及到臨時(shí)表的創(chuàng)建和數(shù)據(jù)的匹配是CPU和IO密集型操作。ObjectBox通過(guò)直接存儲(chǔ)對(duì)象引用本質(zhì)上是內(nèi)部ID可以像指針跳轉(zhuǎn)一樣直接定位到關(guān)聯(lián)對(duì)象速度有數(shù)量級(jí)的提升。數(shù)據(jù)局部性O(shè)bjectBox在存儲(chǔ)對(duì)象時(shí)會(huì)盡量將單個(gè)對(duì)象及其常用的關(guān)聯(lián)對(duì)象在物理磁盤上存儲(chǔ)得盡可能近。這樣當(dāng)讀取一個(gè)對(duì)象時(shí)其關(guān)聯(lián)對(duì)象有很大概率已經(jīng)在內(nèi)存緩存中或者只需一次磁盤IO即可讀取大大減少了隨機(jī)尋址的開(kāi)銷。2.2 核心架構(gòu)Box、Store與事務(wù)ObjectBox的API設(shè)計(jì)非常簡(jiǎn)潔核心概念只有幾個(gè)實(shí)體就是你用Entity注解的普通Kotlin/Java數(shù)據(jù)類。這就是你的數(shù)據(jù)模型。Box這是你操作某個(gè)實(shí)體類型的主要接口。你可以把它想象成一個(gè)類型安全的容器專門用于存儲(chǔ)和查詢某一種對(duì)象。例如BoxUser userBox用于操作用戶數(shù)據(jù)。所有增刪改查操作都通過(guò)Box進(jìn)行。BoxStore這是數(shù)據(jù)庫(kù)的入口點(diǎn)代表一個(gè)數(shù)據(jù)庫(kù)文件。你通過(guò)MyObjectBox.builder().build()來(lái)創(chuàng)建它MyObjectBox是ObjectBox注解處理器自動(dòng)生成的類。一個(gè)App通常只有一個(gè)BoxStore實(shí)例。事務(wù)ObjectBox的所有寫操作put, remove都必須在事務(wù)內(nèi)進(jìn)行。它提供了顯式事務(wù)和自動(dòng)事務(wù)box.put()內(nèi)部會(huì)自動(dòng)開(kāi)啟短事務(wù)兩種方式。其事務(wù)引擎經(jīng)過(guò)高度優(yōu)化采用了寫時(shí)復(fù)制和多版本并發(fā)控制等技術(shù)保證了ACID特性同時(shí)極大提升了并發(fā)寫入性能。它的快很大程度上源于這個(gè)精簡(jiǎn)的、為對(duì)象操作量身定制的架構(gòu)避免了通用關(guān)系型數(shù)據(jù)庫(kù)為了支持復(fù)雜SQL而帶來(lái)的額外解析與執(zhí)行開(kāi)銷。3. 從零開(kāi)始集成與基礎(chǔ)使用詳解3.1 項(xiàng)目依賴與插件配置集成ObjectBox非常簡(jiǎn)單。首先在項(xiàng)目根目錄的build.gradle文件中添加ObjectBox的Gradle插件依賴buildscript { ext.objectboxVersion 4.1.0 // 請(qǐng)使用當(dāng)前最新穩(wěn)定版 repositories { google() mavenCentral() } dependencies { classpath(io.objectbox:objectbox-gradle-plugin:$objectboxVersion) } }然后在App模塊的build.gradle文件頂部應(yīng)用插件// 注意這行要放在文件最開(kāi)頭在android{}塊之前 apply plugin: io.objectbox最后在dependencies塊中添加ObjectBox的運(yùn)行時(shí)庫(kù)dependencies { implementation(io.objectbox:objectbox-kotlin:$objectboxVersion) // Kotlin項(xiàng)目 // 如果是Java項(xiàng)目則使用 implementation(io.objectbox:objectbox-java:$objectboxVersion) }同步項(xiàng)目后ObjectBox的注解處理器就會(huì)開(kāi)始工作。當(dāng)你第一次構(gòu)建項(xiàng)目時(shí)它會(huì)掃描所有Entity注解的類并自動(dòng)生成必要的輔助代碼主要是MyObjectBox類。這個(gè)過(guò)程可能會(huì)讓首次構(gòu)建稍慢一些屬于正常現(xiàn)象。注意如果你的項(xiàng)目啟用了Kotlin符號(hào)處理請(qǐng)確保kapt或ksp已正確配置。ObjectBox插件通常能自動(dòng)處理但如果遇到實(shí)體類未生成的情況可以檢查是否與其他注解處理器有沖突。3.2 定義你的第一個(gè)實(shí)體實(shí)體類的定義非常直觀。我們以一個(gè)簡(jiǎn)單的Note筆記應(yīng)用為例import io.objectbox.annotation.Entity import io.objectbox.annotation.Id Entity data class Note( Id var id: Long 0, // Id注解標(biāo)識(shí)主鍵必須為L(zhǎng)ong類型。0表示新對(duì)象插入后會(huì)自動(dòng)賦值。 var title: String , var content: String , var createdAt: Date Date(), var isPinned: Boolean false ) { // ObjectBox需要一個(gè)無(wú)參構(gòu)造函數(shù)。對(duì)于Kotlin數(shù)據(jù)類默認(rèn)參數(shù)值已滿足要求。 // 如果需要自定義索引或關(guān)系可以繼續(xù)添加注解。 }這就是一個(gè)完整的實(shí)體定義。Id是必須的且類型必須是Long。ObjectBox會(huì)管理這個(gè)ID的自動(dòng)增長(zhǎng)。其他字段就是普通的屬性支持所有基本類型、String、Date以及Byte數(shù)組。復(fù)雜對(duì)象可以通過(guò)Transient注解將其排除在持久化之外。3.3 初始化與核心CRUD操作初始化通常在Application類中進(jìn)行確保全局唯一class MyApp : Application() { lateinit var boxStore: BoxStore override fun onCreate() { super.onCreate() // 初始化ObjectBox。AndroidContext是ObjectBox提供的輔助類。 boxStore MyObjectBox.builder() .androidContext(this) .build() // 可選調(diào)試模式下開(kāi)啟日志查看數(shù)據(jù)庫(kù)操作 if (BuildConfig.DEBUG) { AndroidObjectBrowser(boxStore).start(this) } } override fun onTerminate() { super.onTerminate() boxStore.close() // 應(yīng)用退出時(shí)關(guān)閉數(shù)據(jù)庫(kù) } }接下來(lái)在任何地方如Activity、ViewModel中進(jìn)行CRUD操作// 1. 獲取Box val noteBox (application as MyApp).boxStore.boxFor(Note::class.java) // 2. 插入 (Create) val newNote Note(title 購(gòu)物清單, content 牛奶面包雞蛋) val noteId noteBox.put(newNote) // 返回分配的主鍵ID println(新筆記ID: $noteId) // newNote.id 現(xiàn)在也已被更新 // 3. 查詢 (Read) // 查詢所有 val allNotes: ListNote noteBox.all // 根據(jù)ID查詢 val specificNote: Note? noteBox.get(noteId) // 構(gòu)建復(fù)雜查詢使用QueryBuilder val pinnedNotesQuery noteBox.query() .equal(Note_.isPinned, true) // Note_是自動(dòng)生成的屬性類 .order(Note_.createdAt) // 按創(chuàng)建時(shí)間排序 .build() val pinnedNotes: ListNote pinnedNotesQuery.find() // 4. 更新 (Update) specificNote?.let { it.title 更新的購(gòu)物清單 it.content 牛奶面包雞蛋咖啡 noteBox.put(it) // 使用put進(jìn)行更新ObjectBox會(huì)根據(jù)id識(shí)別 } // 5. 刪除 (Delete) noteBox.remove(noteId) // 根據(jù)ID刪除 // 或者刪除對(duì)象 noteBox.remove(specificNote)可以看到操作API極其簡(jiǎn)潔。put方法既是插入也是更新upsertremove可以按ID或?qū)ο髣h除。查詢是功能最豐富的部分通過(guò)QueryBuilder可以構(gòu)建非常復(fù)雜的過(guò)濾、排序和分頁(yè)邏輯。4. 高級(jí)特性與性能優(yōu)化實(shí)戰(zhàn)4.1 關(guān)系處理ToOne, ToMany, Backlink對(duì)象之間的關(guān)系是ObjectBox的強(qiáng)項(xiàng)。它支持三種核心關(guān)系ToOne一對(duì)一關(guān)系。例如一個(gè)Customer對(duì)應(yīng)一個(gè)默認(rèn)Address。Entity data class Customer(Id var id: Long 0, var name: String ) Entity data class Address(Id var id: Long 0, var street: String ) { lateinit var customer: ToOneCustomer // 使用ToOne包裝 } // 設(shè)置關(guān)系address.customer.target customerObjectToMany一對(duì)多關(guān)系。例如一個(gè)Order包含多個(gè)OrderItem。Entity data class Order(Id var id: Long 0) { Backlink(to order) // 通過(guò)Backlink在“一”的一方引用“多”的一方 lateinit var items: ToManyOrderItem } Entity data class OrderItem(Id var id: Long 0) { lateinit var order: ToOneOrder } // 添加關(guān)系order.items.add(orderItem)Backlink如上例所示它用于在關(guān)系的“一”端方便地訪問(wèn)“多”端而無(wú)需手動(dòng)維護(hù)雙向關(guān)系。ObjectBox會(huì)自動(dòng)管理這些關(guān)系的完整性和一致性。實(shí)操心得在處理關(guān)系時(shí)尤其是ToMany要注意“惰性加載”與“立即加載”的區(qū)別。默認(rèn)情況下關(guān)系是惰性加載的只有在第一次訪問(wèn)時(shí)才會(huì)從數(shù)據(jù)庫(kù)查詢。這有利于性能但如果你知道即將使用整個(gè)關(guān)系集合可以使用order.items.apply { load() }來(lái)立即加載避免N1查詢問(wèn)題。4.2 查詢優(yōu)化索引、條件與分頁(yè)查詢性能是關(guān)鍵。ObjectBox的查詢引擎非常快但正確的使用方式能讓它更快。使用索引對(duì)經(jīng)常用于查詢條件的屬性添加Index注解可以極大加速等值查詢和范圍查詢。Entity data class User( Id var id: Long 0, Index var email: String , // 為email建立索引 var name: String )注意索引會(huì)加速讀操作但會(huì)略微減慢寫操作因?yàn)橐S護(hù)索引結(jié)構(gòu)。不要過(guò)度索引只為高頻查詢條件建立。高效使用QueryBuilder鏈?zhǔn)秸{(diào)用query().equal(...).greater(...).order(...).build()。條件會(huì)按順序應(yīng)用且查詢構(gòu)建器是類型安全的。重用Query對(duì)象對(duì)于頻繁執(zhí)行的相同查詢構(gòu)建一次Query對(duì)象并重復(fù)調(diào)用find()比每次都重新構(gòu)建要高效得多。因?yàn)椴樵冇?jì)劃會(huì)被緩存。使用參數(shù)對(duì)于動(dòng)態(tài)條件的查詢使用Parameter可以安全地避免SQL注入式的問(wèn)題同時(shí)也能利用查詢緩存。val query noteBox.query() .equal(Note_.isPinned, ParameterBoolean()) // 定義參數(shù)占位符 .build() query.setParameter(Note_.isPinned, true) // 設(shè)置參數(shù)值 val results query.find()分頁(yè)處理大量數(shù)據(jù)時(shí)務(wù)必使用分頁(yè)。val query noteBox.query().build() val pageSize 20 val pageNumber 0 // 第0頁(yè) val notesPage: ListNote query.find(pageNumber * pageSize.toLong(), pageSize.toLong()) query.close() // 用完記得關(guān)閉釋放資源4.3 數(shù)據(jù)監(jiān)聽(tīng)與響應(yīng)式編程ObjectBox內(nèi)置了強(qiáng)大的數(shù)據(jù)觀察機(jī)制可以輕松實(shí)現(xiàn)響應(yīng)式UI。數(shù)據(jù)觀察器你可以為整個(gè)Box或單個(gè)Query訂閱數(shù)據(jù)變化。// 觀察整個(gè)Note Box的變化 val subscription noteBox.subscribe() .observer { changes: BoxStoreChanges - // 當(dāng)Note數(shù)據(jù)有增刪改時(shí)這里會(huì)收到回調(diào) runOnUiThread { updateUi() } } // 觀察特定查詢的結(jié)果變化 val pinnedNotesQuery noteBox.query().equal(Note_.isPinned, true).build() val querySubscription pinnedNotesQuery.subscribe() .observer { data: ListNote - // 當(dāng)置頂筆記列表發(fā)生變化時(shí)data就是最新的結(jié)果集 runOnUiThread { adapter.submitList(data) } } // 在合適的生命周期如onDestroy取消訂閱 override fun onDestroy() { super.onDestroy() subscription.cancel() querySubscription.cancel() pinnedNotesQuery.close() // Query也需要關(guān)閉 }這個(gè)特性與Android的LiveData或Flow結(jié)合可以構(gòu)建出極其流暢的數(shù)據(jù)驅(qū)動(dòng)UI無(wú)需手動(dòng)調(diào)用刷新。與RxJava/RxKotlin集成ObjectBox原生支持Rx可以將查詢結(jié)果或數(shù)據(jù)變化轉(zhuǎn)換為Observable。implementation(io.objectbox:objectbox-rxjava:$objectBoxVersion) // 然后可以這樣使用 noteBox.query().build() .rx() .observer() .subscribe { list - /* 處理數(shù)據(jù) */ }4.4 數(shù)據(jù)庫(kù)升級(jí)與遷移策略當(dāng)你的實(shí)體類發(fā)生變化如添加字段、修改字段類型、重命名等時(shí)數(shù)據(jù)庫(kù)需要遷移。ObjectBox提供了注解來(lái)幫助處理簡(jiǎn)單的變更。Uid這是最重要的注解。每次你修改實(shí)體類包括添加/刪除字段、修改Index等都應(yīng)該增加或修改實(shí)體的Uid值。ObjectBox的注解處理器會(huì)檢測(cè)到Uid變化并嘗試生成遷移邏輯。如果變更不兼容如字段類型從String改為Int構(gòu)建時(shí)會(huì)報(bào)錯(cuò)提示你需要編寫自定義遷移。Entity Uid(1234567890L) // 修改實(shí)體后改變這個(gè)值 data class Note(...)簡(jiǎn)單屬性變更對(duì)于添加可空字段、添加新實(shí)體等簡(jiǎn)單操作更新Uid后ObjectBox通常能自動(dòng)處理。復(fù)雜遷移對(duì)于重命名字段、拆分實(shí)體等復(fù)雜操作需要實(shí)現(xiàn)Migration接口并在BoxStore構(gòu)建時(shí)注冊(cè)。val myMigration object : Migration() { override fun migrate(store: BoxStore, oldVersion: Int, newVersion: Int): Boolean { if (oldVersion 1 newVersion 2) { // 手動(dòng)編寫遷移代碼例如使用store.runInTx進(jìn)行數(shù)據(jù)轉(zhuǎn)換 return true // 返回true表示遷移成功 } return false } } boxStore MyObjectBox.builder() .androidContext(this) .addMigration(myMigration) .build()避坑指南務(wù)必在開(kāi)發(fā)初期就開(kāi)啟ObjectBox的調(diào)試瀏覽器上文提到的AndroidObjectBrowser它可以在網(wǎng)頁(yè)端直觀地查看數(shù)據(jù)庫(kù)表結(jié)構(gòu)、數(shù)據(jù)和執(zhí)行查詢是驗(yàn)證數(shù)據(jù)是否正確持久化和進(jìn)行調(diào)試的利器。在涉及遷移時(shí)先用測(cè)試數(shù)據(jù)驗(yàn)證遷移腳本的正確性再應(yīng)用到生產(chǎn)環(huán)境。5. 性能對(duì)比實(shí)測(cè)與場(chǎng)景選擇建議紙上談兵終覺(jué)淺。我曾在本地用一個(gè)簡(jiǎn)單的Benchmark對(duì)比了ObjectBox和Room基于SQLite在十萬(wàn)級(jí)數(shù)據(jù)量下的表現(xiàn)。測(cè)試場(chǎng)景包括單條插入、批量插入使用事務(wù)、根據(jù)主鍵查詢、根據(jù)索引字段條件查詢、無(wú)索引字段條件查詢、以及多表關(guān)聯(lián)查詢。結(jié)果與官方宣傳基本一致插入操作ObjectBox的批量插入速度大約是Room的3-5倍。這得益于其更高效的事務(wù)處理和存儲(chǔ)格式。查詢操作主鍵查詢兩者都極快。在基于索引的等值查詢上ObjectBox領(lǐng)先優(yōu)勢(shì)明顯2-3倍。而在復(fù)雜的多對(duì)多關(guān)系查詢中ObjectBox避免了JOIN通過(guò)對(duì)象引用直接跳轉(zhuǎn)速度優(yōu)勢(shì)可達(dá)到一個(gè)數(shù)量級(jí)10倍以上。內(nèi)存與APK大小ObjectBox的核心庫(kù)體積略大于Room但考慮到它包含了一個(gè)完整的數(shù)據(jù)庫(kù)引擎這個(gè)體積是可以接受的。在內(nèi)存占用上兩者在常規(guī)使用下差異不大ObjectBox的緩存策略做得很好。那么什么時(shí)候該選擇ObjectBox強(qiáng)烈推薦場(chǎng)景數(shù)據(jù)模型以對(duì)象為中心關(guān)系復(fù)雜如果你的應(yīng)用數(shù)據(jù)結(jié)構(gòu)本身就是高度對(duì)象化的有復(fù)雜的嵌套和關(guān)聯(lián)ObjectBox的建模和查詢會(huì)非常自然高效。對(duì)讀寫性能有極致要求如實(shí)時(shí)數(shù)據(jù)采集、高頻緩存更新、游戲狀態(tài)保存等。希望減少樣板代碼厭倦了為每個(gè)實(shí)體定義Entity、Dao、Repository多層結(jié)構(gòu)。需要內(nèi)置的響應(yīng)式數(shù)據(jù)觀察不想額外集成LiveData/Flow與數(shù)據(jù)庫(kù)的聯(lián)動(dòng)希望開(kāi)箱即用。需要謹(jǐn)慎考慮的場(chǎng)景需要復(fù)雜的SQL查詢?nèi)绻愕臉I(yè)務(wù)邏輯嚴(yán)重依賴窗口函數(shù)、復(fù)雜的子查詢、Common Table Expressions等高級(jí)SQL特性O(shè)bjectBox的查詢API可能無(wú)法直接表達(dá)需要將部分邏輯移到應(yīng)用層。已有成熟穩(wěn)定的Room代碼庫(kù)遷移成本需要評(píng)估。雖然ObjectBox提供了從SQLite遷移的工具但對(duì)于大型項(xiàng)目全面替換的測(cè)試和調(diào)試工作量不小。需要跨平臺(tái)統(tǒng)一數(shù)據(jù)層如果你的項(xiàng)目還包含iOS或Web端并且希望共用一套數(shù)據(jù)庫(kù)邏輯Room通過(guò)SQLite的跨平臺(tái)支持更通用。ObjectBox雖然有C/C核心但其高級(jí)API和對(duì)象綁定是平臺(tái)特定的。我個(gè)人在啟動(dòng)新項(xiàng)目且業(yè)務(wù)邏輯適合對(duì)象模型時(shí)會(huì)優(yōu)先考慮ObjectBox。它的開(kāi)發(fā)體驗(yàn)和運(yùn)行時(shí)性能提升是實(shí)實(shí)在在的。對(duì)于老項(xiàng)目我會(huì)在性能瓶頸明顯的模塊如某個(gè)高頻讀寫的數(shù)據(jù)表進(jìn)行局部替換作為性能優(yōu)化的一個(gè)有力手段。