简介:这是一份面向iOS/macOS原生开发者的技术资源包,提供Couchbase Lite嵌入式NoSQL数据库的完整本地实现,专为解决移动应用离线数据存储与跨设备实时同步难题而设计。资源适用于需构建高可靠性同步能力的应用场景,如即时通讯、移动办公及物联网终端等,尤其适合具备Swift/Objective-C开发经验的中高级工程师快速集成文档型本地数据库。压缩包共623个文件,涵盖183个头文件(.h)、126个Swift源码、121个Objective-C实现(.m)及41个混合代码文件(.mm),辅以xcconfig配置、xcscheme工程定义、shell脚本与证书(.cer/.der/.p12)等关键构建要素,整体体积仅4.19MB,结构清晰、开箱即用。目前已有34人学习下载,读者可直接获取完整的数据库引擎源码、同步协议实现、本地查询模块及配套证书体系,无需额外编译即可理解其与Couchbase Server协同工作的核心机制。
1. 项目概述:为什么我们需要一个“口袋里的”数据库?
如果你是一名移动端或桌面端应用的开发者,尤其是需要处理离线场景、复杂数据模型和跨设备同步的,那么你一定对本地数据存储的复杂性深有体会。Core Data 太重,SQLite 写起来繁琐,UserDefaults 又太简陋。这时候,一个轻量级、嵌入式、支持文档模型并能自动同步的数据库,就成了刚需。Couchbase Lite 正是为此而生。
简单来说,Couchbase Lite 是一个专为移动和边缘设备设计的嵌入式 NoSQL 数据库引擎。它不是一个需要独立安装的服务,而是一个可以直接打包进你的 iOS、macOS、Android 甚至 Windows 应用里的库。它的核心价值在于两点:第一,提供了灵活、高性能的 JSON 文档存储与查询能力;第二,内置了强大的数据同步协议,可以轻松地与远程的 Couchbase Server 或云服务进行双向数据同步。这意味着你的应用可以无缝地在离线与在线状态间切换,数据在后台自动处理冲突和合并,为用户提供始终如一的体验。
我最初接触它是在开发一个户外作业的巡检应用时。工人们在地下室或偏远山区,网络时有时无,但他们采集的表格、照片、地理位置信息必须实时保存,并在有网时自动上传到中心服务器,同时接收最新的任务单。Couchbase Lite 的“同步网关”(Sync Gateway)模式完美解决了这个痛点。它不像传统的 REST API 需要自己处理队列、重试和冲突解决,而是将数据库级别的同步抽象成了一个简单的配置。对于需要构建响应式、离线优先应用(如协作编辑、物联网数据采集、零售业库存管理)的开发者来说,这无疑是一个利器。
2. 核心架构与设计哲学拆解
2.1 文档模型与 SQLite 的思维转换
Couchbase Lite 的核心数据模型是 JSON 文档。这和我们熟悉的 SQLite 关系型模型有根本区别。在 SQLite 里,我们设计表结构,定义字段类型,通过外键关联数据。而在 Couchbase Lite 中,一个“文档”就是一个自包含的 JSON 对象,它拥有一个唯一的 ID,文档内部可以嵌套数组和子文档。
这种设计带来了巨大的灵活性。假设我们要存储一个“订单”数据。在 SQL 中,我们可能需要orders、order_items、products等多张表。在 Couchbase Lite 中,一个订单文档就可以包含所有信息:
{ “id”: “order_12345”, “type”: “order”, “customerId”: “cust_001”, “date”: “2023-10-27T10:30:00Z”, “items”: [ { “productId”: “prod_1”, “name”: “T-Shirt”, “quantity”: 2, “price”: 25.99 }, { “productId”: “prod_2”, “name”: “Mug”, “quantity”: 1, “price”: 12.50 } ], “totalAmount”: 64.48, “shippingAddress”: { “street”: “123 Main St”, “city”: “Anytown”, “zip”: “12345” } }这种“反规范化”的存储方式,非常适合移动端一次读取就能渲染整个视图的场景,避免了复杂的联表查询。但这也要求开发者转变思维:从思考“如何设计规范的表结构”转变为“如何设计高效、自包含的文档结构”。
注意:灵活性不等于随意性。文档结构仍然需要精心设计。过度嵌套或文档过大(超过几十KB)会影响查询和同步性能。一个好的实践是,将频繁独立访问的实体(如用户资料)和可能无限增长的数据(如聊天记录)拆分成不同的文档,通过文档ID进行逻辑关联。
2.2 数据同步的核心:Couchbase 同步协议
Couchbase Lite 最吸引人的特性莫过于其开箱即用的数据同步能力。这背后是 Couchbase 自定义的同步协议,它基于 WebSocket 或 HTTP 长轮询,实现了一个多主复制的模型。
它的工作流程可以这样理解:
- 变更追踪:本地数据库的任何增删改操作,都会被记录到一个“修订历史”中。每个文档的每次更改都会生成一个唯一的
revision ID。 - 推送 (Push):当网络可用时,Couchbase Lite 会将本地的变更集合(一个修订列表)打包,发送给远端的同步端点(通常是 Sync Gateway)。
- 拉取 (Pull):同时,它也会从同步端点拉取其他设备或服务器上发生的、自己尚未拥有的变更。
- 冲突解决:如果同一个文档在不同端被同时修改,就会产生冲突。Sync Gateway 或客户端可以配置冲突解决策略,如“服务端获胜”、“客户端获胜”,或者执行自定义的合并逻辑(例如,合并两个文档的特定字段)。
这个模型的美妙之处在于,它对应用层是透明的。开发者只需要配置同步的方向(持续推送、持续拉取、一次性拉取)和过滤条件(同步哪些通道或文档ID),剩下的网络重试、数据压缩、增量传输等脏活累活都由 SDK 自动完成。
2.3 与生态的集成:Sync Gateway 与 Couchbase Server
Couchbase Lite 通常不直接与 Couchbase Server 集群对话,中间有一个关键组件:Sync Gateway。你可以把 Sync Gateway 看作一个智能的同步代理和流量控制器。
- 身份验证与授权:Sync Gateway 可以集成各种认证提供商(如自带的用户/密码、OAuth2、OpenID Connect)。更重要的是,它引入了“通道”的概念。每个文档可以被分配到一个或多个通道,每个用户可以被授予访问特定通道的权限。这天然地实现了数据的分区与多租户隔离。例如,公司A的用户只能访问“channel_company_a”通道里的文档。
- 数据路由与过滤:Sync Gateway 根据用户的通道权限,只同步该用户有权访问的文档给对应的 Couchbase Lite 客户端。这极大地减少了移动设备上不必要的数据传输和存储。
- 函数钩子:Sync Gateway 允许你编写 JavaScript 函数,在文档同步前(
sync function)或同步后(post-update)执行逻辑,用于验证数据、计算衍生字段、触发外部服务等。
而 Couchbase Server 则作为整个系统的“单一数据源”,负责海量数据的持久化、集群管理和高并发在线查询。这样一个三层架构(移动端 Couchbase Lite -> Sync Gateway -> Couchbase Server)构成了一个完整、可扩展的离线优先应用后端。
3. 从零开始:在 iOS/macOS 项目中集成与基础操作
3.1 环境配置与数据库初始化
集成 Couchbase Lite 非常直接。对于 Swift Package Manager,你只需要在 Xcode 中添加其 GitHub 仓库的 URL。CocoaPods 和 Carthage 也同样支持。
安装完成后,第一步是初始化数据库。这里有几个关键参数需要理解:
import CouchbaseLiteSwift // 1. 初始化数据库配置 var config = DatabaseConfiguration() // 指定数据库目录,默认为应用的 Documents 目录 if let dir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first { config.directory = dir.path } // 2. 创建或打开数据库 let database: Database do { database = try Database(name: “myapp”, config: config) } catch { print(“无法打开数据库: \(error)”) return }- 数据库目录:强烈建议显式设置一个目录。默认目录在 iOS 上可能受 iCloud 备份影响,且在某些情况下(如 App Groups 共享)需要指定到共享容器内。
- 数据库名称:名称中避免使用特殊字符,它最终会对应一个文件夹下的
.cblite2目录。
实操心得:在真实项目中,我会将数据库实例包装在一个单例或依赖注入容器中管理。同时,考虑到数据库文件可能增长,要确保你的应用有相应的数据清理或归档策略,特别是对于同步了大量历史数据的场景。
3.2 文档的 CRUD 操作详解
让我们看看如何操作一个文档。
创建/更新文档: 文档以MutableDocument对象的形式存在,你可以像字典一样操作它。
// 创建新文档 let newTask = MutableDocument() newTask.setString(“task”, forKey: “type”) newTask.setString(“Buy groceries”, forKey: “title”) newTask.setBoolean(false, forKey: “completed”) newTask.setDate(Date(), forKey: “createdAt”) // 可以设置复杂的嵌套值 newTask.setArray([“milk”, “eggs”, “bread”], forKey: “items”) // 保存到数据库(如果文档ID不存在则创建,存在则更新) try database.saveDocument(newTask) // 更新现有文档 if let existingDoc = database.document(withID: newTask.id)?.toMutable() { existingDoc.setBoolean(true, forKey: “completed”) try database.saveDocument(existingDoc) }读取文档: 通过 ID 获取文档是最快的方式。
if let document = database.document(withID: “some_doc_id”) { let title = document.string(forKey: “title”) // 安全获取 String 类型值 let items = document.array(forKey: “items”)?.toArray() as? [String] // 整个文档可以转为 Dictionary let dict = document.toDictionary() }删除文档: 删除操作也会产生一个新的修订版本,以便在同步时告知其他节点此文档已被删除。
if let doc = database.document(withID: “doc_to_delete”) { try database.deleteDocument(doc) } // 或者使用 purge 彻底清除(不进入同步流,不可恢复) try database.purgeDocument(withID: “doc_to_purge”)3.3 查询:使用 QueryBuilder 与 N1QL
Couchbase Lite 提供了两种主要的查询方式:QueryBuilder API(类型安全、流畅接口)和N1QL(SQL for JSON)。对于 iOS/macOS 开发,QueryBuilder 更符合 Swift 的习惯。
假设我们要查询所有未完成的任务,并按创建时间排序:
import CouchbaseLiteSwift let query = QueryBuilder .select(SelectResult.all()) // 选择所有属性 .from(DataSource.database(database)) .where( Expression.property(“type”).equalTo(Expression.string(“task”)) .and(Expression.property(“completed”).equalTo(Expression.boolean(false))) ) .orderBy(Ordering.property(“createdAt”).ascending()) do { let results = try query.execute() for result in results { // result 是一个 DictionaryObject if let dict = result.toDictionary() { print(“Task: \(dict[“title”] ?? “”)”) } // 或者通过 key 直接访问 let title = result.string(forKey: “title”) } } catch { print(“查询失败: \(error)”) }索引优化:对于频繁查询的字段,创建索引能大幅提升性能。例如,为type和completed创建复合索引:
let index = IndexBuilder.valueIndex(items: [ ValueIndexItem.expression(Expression.property(“type”)), ValueIndexItem.expression(Expression.property(“completed”)) ]) try database.createIndex(index, withName: “idx_type_completed”)注意事项:索引会加快查询,但会增加写操作的开销和数据库文件大小。只为最关键的查询路径创建索引。对于全文搜索,Couchbase Lite 还支持功能强大的全文索引,可以高效地在文本字段中搜索单词。
4. 实现数据同步:配置、监听与冲突处理
4.1 配置同步端点与复制器
同步的核心是Replicator对象。你需要一个同步端点 URL(指向 Sync Gateway)和一个有效的认证方式。
// 1. 定义同步目标(这里以本地 Sync Gateway 为例) let targetEndpoint = URLEndpoint(url: URL(string: “ws://localhost:4984/mydb”)!) // WebSocket 协议 // 或 URLEndpoint(url: URL(string: “http://localhost:4984/mydb”)!) // HTTP 协议 // 2. 配置复制器 var config = ReplicatorConfiguration(database: database, target: targetEndpoint) config.replicatorType = .pushAndPull // 双向同步 config.continuous = true // 持续同步(长连接),false 则为一次性同步 // 3. 配置通道过滤(可选,通常由 Sync Gateway 的 sync function 控制) // config.channels = [“channel_user_\(userId)”] // 4. 配置身份验证(这里使用基本认证) config.authenticator = BasicAuthenticator(username: “username”, password: “password”) // 5. 创建并启动复制器 let replicator = Replicator(config: config) // 添加状态变更监听器 let token = replicator.addChangeListener { change in let status = change.status print(“同步器状态: \(status.activity) - \(status.progress.completed)/\(status.progress.total)”) if let error = status.error { print(“同步错误: \(error)”) } } replicator.start()关键参数解析:
replicatorType:.push(仅上传)、.pull(仅下载)或.pushAndPull(双向)。continuous: 设置为true时,复制器会建立一个持久连接,实时监听变更。这是实现“实时同步”体验的关键。对于只需要偶尔同步(如手动刷新)的场景,可以设为false,然后在需要时调用start()。authenticator: 支持BasicAuthenticator、SessionAuthenticator(用于 Cookie 或 Token 认证)等。生产环境通常使用基于 Token 的认证。
4.2 监听同步进度与网络状态
复制器的状态监听至关重要,它让你能在 UI 上展示同步状态(如“正在同步...”或“同步失败”)。
replicator.addChangeListener { change in let status = change.status switch status.activity { case .stopped: print(“同步已停止”) // 可能是用户登出,或手动停止 case .offline: print(“网络离线”) // 可以在这里提示用户检查网络 case .connecting: print(“正在连接同步网关...”) case .idle: print(“同步空闲,所有变更已处理完毕”) // 这是一个很好的时机更新 UI,通知用户数据已最新 case .busy: print(“同步进行中,已处理 \(status.progress.completed)/\(status.progress.total)”) // 可以更新进度条 } if let error = status.error { // 处理错误,例如认证失败、网络超时等 handleSyncError(error) } }在实际应用中,我通常会将这些状态绑定到 ViewModel 的@Published属性上,从而驱动 UI 的状态更新(如显示一个小的同步指示器)。
4.3 处理同步冲突的策略
冲突是分布式系统的常态。Couchbase Lite 提供了自动和手动两种解决方式。
自动解决策略:在ReplicatorConfiguration中设置。
config.conflictResolver = ConflictResolver.default // 默认,服务端获胜 // 或 config.conflictResolver = ConflictResolver.localWins // 本地获胜这适用于简单的、对数据一致性要求不苛刻的场景。
自定义冲突解决:对于业务逻辑复杂的冲突(如合并购物车商品),你需要实现自己的ConflictResolver。
class CustomConflictResolver: ConflictResolver { func resolve(conflict: Conflict) -> Document? { // conflict.remoteDocument: 远程服务器上的版本 // conflict.localDocument: 设备本地的版本 // conflict.baseDocument: 冲突发生前的共同祖先版本(可能为nil) guard let localDoc = conflict.localDocument?.toMutable(), let remoteDoc = conflict.remoteDocument else { // 如果一方文档被删除,可以决定返回另一方或nil return conflict.remoteDocument ?? conflict.localDocument } // 示例:合并“items”数组,去重 let localItems = localDoc.array(forKey: “items”)?.toArray() as? [String] ?? [] let remoteItems = remoteDoc.array(forKey: “items”)?.toArray() as? [String] ?? [] let mergedItems = Array(Set(localItems + remoteItems)) localDoc.setArray(mergedItems, forKey: “items”) // 可以选择保留最新的时间戳 let latestDate = max(localDoc.date(forKey: “updatedAt”) ?? Date.distantPast, remoteDoc.date(forKey: “updatedAt”) ?? Date.distantPast) localDoc.setDate(latestDate, forKey: “updatedAt”) return localDoc } } // 应用自定义解析器 config.conflictResolver = CustomConflictResolver()重要提示:冲突解决逻辑必须保证幂等性和确定性。即给定相同的本地、远程和基础文档,无论运行多少次,结果都应该相同。复杂的冲突解决最好在 Sync Gateway 的
sync function中实现,以保证所有客户端遵循同一套规则。
5. 高级特性与性能优化实战
5.1 使用预构建数据库加速首次启动
对于包含大量初始数据(如产品目录、城市列表)的应用,将数据打包在应用内,首次启动时直接加载,比通过网络同步要快得多。这可以通过“预构建数据库”实现。
- 在开发环境准备数据:在你的桌面或服务器上,使用 Couchbase Lite 的 .NET 或 Java 版本创建一个数据库,并导入所有初始数据。
- 打包数据库文件:将生成的
.cblite2目录整个压缩,放入应用的资源包(Asset Catalog 或 Bundle)中。 - 应用首次启动时复制:
func setupDatabase() throws -> Database { let dbName = “myapp” let finalDBPath = // ... 最终数据库路径 if !Database.exists(withName: dbName, inDirectory: finalDBPath) { // 从应用包中复制预构建数据库 guard let prebuiltDBURL = Bundle.main.url(forResource: “prebuilt”, withExtension: “cblite2”) else { throw NSError(domain: “AppError”, code: -1, userInfo: [NSLocalizedDescriptionKey: “预构建数据库未找到”]) } try Database.copy(from: prebuiltDBURL, toDatabase: “myapp”, withConfig: nil) } // 打开数据库 return try Database(name: dbName) }5.2 数据库加密保障数据安全
移动设备可能丢失或被盗,对本地数据库加密是保护用户敏感信息的必要措施。Couchbase Lite 支持使用 SQLCipher 进行 AES-256 加密。
import CouchbaseLiteSwift var config = DatabaseConfiguration() // ... 设置目录 // 创建或提供加密密钥(务必安全存储,例如使用钥匙串) let encryptionKey = EncryptionKey.password(“YourStrongPassword!”) config.encryptionKey = encryptionKey let database = try Database(name: “secureDB”, config: config)密钥管理要点:
- 切勿将密钥硬编码在代码中。
- 对于 iOS/macOS,使用系统的Keychain Services来安全地生成、存储和检索加密密钥。
- 如果用户有密码,可以考虑基于用户密码派生密钥。但要注意,一旦密钥丢失,数据库将永远无法打开。
- 你可以在数据库创建后随时启用、禁用或更改加密密钥(通过
Database.changeEncryptionKey(_:)方法)。
5.3 监听数据变更与驱动 UI 更新
Couchbase Lite 提供了高效的变更监听 API,让你可以轻松实现响应式 UI。
监听单个文档:
let docId = “task_123” let token = database.addDocumentChangeListener(id: docId) { change in print(“文档 \(change.documentID) 发生了变更”) if change.document != nil { // 文档存在(被创建或更新) } else { // 文档被删除 } } // 记得在适当的时候移除监听器(例如 deinit 中) // database.removeChangeListener(withToken: token)监听查询结果集(LiveQuery): 这非常强大,相当于一个可观察的查询。当查询结果中的任何文档发生变化,或新文档符合查询条件时,监听器都会被触发。
let query = QueryBuilder .select(SelectResult.expression(Meta.id)) .from(DataSource.database(database)) .where(Expression.property(“type”).equalTo(Expression.string(“task”))) let token = query.addChangeListener { change in guard let results = change.results else { return } print(“查询结果集更新了,共有 \(results.count) 个任务”) // 在这里刷新你的 UITableView 或 SwiftUI List self?.updateUI(with: results) } query.execute() // 开始监听在 SwiftUI 中,你可以将LiveQuery的结果包装成一个@Published属性,实现数据库到 UI 的自动绑定。
5.4 性能调优与最佳实践
批量操作:进行大量文档写入时,使用
Database.inBatch(_:)可以显著提升性能,因为它将多次磁盘 I/O 合并为一次事务。try database.inBatch { for item in largeItemArray { let doc = MutableDocument() // ... 设置数据 try database.saveDocument(doc) } }控制文档大小:避免创建过大的单个文档(如将整个相册的 Base64 图片数据存为一个字段)。大文档会影响查询、同步和内存使用。应将大块二进制数据(BLOB)存储在文件系统中,而只在文档中保存文件的引用路径。
明智地使用索引:只为最频繁的查询创建索引。使用
EXPLAIN语句(通过Query.explain())分析查询计划,判断是否使用了索引。同步调优:
- 心跳间隔:在
ReplicatorConfiguration中设置heartbeat间隔(如 300 秒),以在长时间无数据流动时保持 WebSocket 连接活跃,避免被中间网络设备断开。 - 重试逻辑:复制器内置了指数退避的重试机制,通常不需要手动处理。但你可以通过监听错误状态,在特定错误(如认证失败)时采取不同策略。
- 选择性同步:利用 Sync Gateway 的通道和
docIDs过滤器,只同步用户需要的数据子集,这对拥有海量数据的应用至关重要。
- 心跳间隔:在
6. 疑难杂症与故障排查实录
即使设计得再完善,在实际开发中也会遇到各种问题。下面是我和团队踩过的一些坑以及解决方案。
6.1 常见错误与解决方案速查表
| 问题现象 | 可能原因 | 排查步骤与解决方案 |
|---|---|---|
| 数据库无法打开 | 数据库文件损坏、加密密钥错误、目录权限不足。 | 1. 检查DatabaseConfiguration.directory路径是否可写。2. 确认加密密钥与创建数据库时使用的一致。 3. 尝试使用 Database.exists检查文件是否存在。如果怀疑损坏,尝试从备份恢复。 |
| 同步器无法连接 | Sync Gateway 地址/端口错误、网络问题、SSL证书问题(Android/iOS 对证书要求严格)。 | 1. 使用curl或浏览器测试 Sync Gateway 的/_all_docs端点是否可达。2. 检查设备网络代理设置。 3. 对于自签名证书,需要在 ReplicatorConfiguration中设置acceptOnlySelfSignedServerCertificate为false(仅限开发环境!)。生产环境必须使用有效证书。 |
| 同步一直停留在“连接中”或“空闲”但无数据流动 | 认证失败、用户无通道访问权限、Sync Gateway 的sync function过滤了所有文档。 | 1. 查看 Sync Gateway 日志,确认认证是否成功。 2. 检查 Sync Gateway 配置中,该用户是否被分配了正确的通道。 3. 在 Sync Gateway 的 sync function中添加console.log,查看文档是否被正确路由。 |
| 文档更新后,同步未触发 | 更改未保存、复制器未启动或未设置为持续同步、文档不在同步范围内。 | 1. 确认database.saveDocument调用成功且无异常。2. 确认 replicator.start()已被调用且continuous = true。3. 检查文档的 channels属性(如果使用)是否在用户权限内。 |
应用崩溃,日志提示CouchbaseLite相关错误 | 多线程访问数据库违规、监听器未正确移除导致内存泄漏。 | 1.Couchbase Lite 对象不是线程安全的。确保所有数据库操作、文档访问和查询执行都在同一个串行队列中进行。我通常会创建一个专用的DispatchQueue来管理所有数据库交互。2. 在视图控制器或对象销毁时,使用 removeChangeListener移除所有监听器。 |
| 数据库文件体积增长过快 | 未压缩的附件、过多的修订历史、未清理的已删除文档。 | 1. 对于附件,使用BlobAPI 并让 SDK 自动压缩。2. 考虑启用数据库的自动压缩功能( Database.performMaintenance(type: .compact)),但需在应用空闲时手动触发。3. 定期清理已删除的文档( purge)或使用DatabaseConfiguration的maxRevTreeDepth限制修订历史深度。 |
6.2 调试与日志收集
当问题难以定位时,详细的日志是救命稻草。
启用更详细的日志:
// 在应用启动早期调用 Database.log.console.domains = .all // 输出所有域日志 Database.log.console.level = .verbose // 设置为最详细的级别这会在 Xcode 控制台输出大量内部信息,包括网络请求、SQL 语句等,对排查同步和查询问题极有帮助。
获取数据库诊断信息:
let diagnostics = DatabaseDiagnostic(database: database) print(“数据库路径: \(diagnostics.path)”) print(“文档数量: \(diagnostics.count)”) print(“最后序列号: \(diagnostics.lastSequence)”) // 可以将其打包发送到你的错误分析平台网络抓包:对于同步问题,使用像Proxyman或Charles这样的工具抓取设备与 Sync Gateway 之间的 HTTP/WebSocket 流量,可以清晰地看到握手、认证、数据推送/拉取的全过程,是诊断网络层问题的终极手段。
6.3 一个典型的内存管理陷阱
一个常见的错误模式是在后台线程中捕获了数据库对象(如查询结果),然后在主线程中访问它。由于 Couchbase Lite 对象底层关联着 C++ 资源,跨线程访问会导致未定义行为甚至崩溃。
错误示例:
DispatchQueue.global(qos: .background).async { let results = try? self.query.execute() DispatchQueue.main.async { // 危险!`results` 是在后台线程创建的 ResultSet self.uiResults = results // 可能导致后续访问时崩溃 } }正确做法:要么将所有数据库操作封装到一个串行队列中,要么在切换线程前,将从数据库获取的数据转换为纯值类型(如Array、Dictionary)。
DispatchQueue.global(qos: .background).async { let results = try? self.query.execute() let dataArray = results?.allResults().map { $0.toDictionary() } ?? [] // 转换为值类型 DispatchQueue.main.async { self.uiData = dataArray // 安全 } }7. 项目实战:构建一个离线优先的笔记应用
理论说了这么多,我们通过一个简化版的笔记应用来串联核心概念。这个应用允许用户创建、编辑、删除笔记,并在所有设备间自动同步。
7.1 数据模型设计
我们设计一个简单的note文档。
{ “id”: “note_001”, “type”: “note”, “title”: “购物清单”, “content”: “牛奶,鸡蛋,面包”, “createdAt”: “2023-10-27T10:00:00Z”, “updatedAt”: “2023-10-27T11:30:00Z”, “tags”: [“personal”, “shopping”], “channel”: “user_alice” // 用于同步通道过滤 }id: 我们使用自定义 ID(前缀+UUID),便于识别文档类型。type: 固定字段,便于查询时过滤。channel: 对应 Sync Gateway 中的通道,我们计划每个用户一个通道。
7.2 实现数据层管理器
我们创建一个DatabaseManager单例来封装所有 Couchbase Lite 操作。
import CouchbaseLiteSwift import Combine class DatabaseManager { static let shared = DatabaseManager() private let database: Database private let serialQueue = DispatchQueue(label: “com.example.notebook.db”) private var replicator: Replicator? private var listenerTokens = [ListenerToken]() private init() { // 初始化数据库(略,见上文) // 创建笔记类型和 channel 的复合索引 try? createIndexes() } // MARK: - CRUD func saveNote(id: String? = nil, title: String, content: String, tags: [String], channel: String) throws -> String { var docId = id ?? “note_\(UUID().uuidString)” try serialQueue.sync { let doc = MutableDocument(id: docId) doc.setString(“note”, forKey: “type”) doc.setString(title, forKey: “title”) doc.setString(content, forKey: “content”) doc.setArray(tags, forKey: “tags”) doc.setString(channel, forKey: “channel”) doc.setDate(Date(), forKey: “updatedAt”) if id == nil { doc.setDate(Date(), forKey: “createdAt”) } try database.saveDocument(doc) } return docId } func fetchNotes(forChannel channel: String) -> [DictionaryObject] { // 使用 LiveQuery 监听变化更佳,这里简化为一次性查询 let query = QueryBuilder .select(SelectResult.all()) .from(DataSource.database(database)) .where( Expression.property(“type”).equalTo(Expression.string(“note”)) .and(Expression.property(“channel”).equalTo(Expression.string(channel))) ) .orderBy(Ordering.property(“updatedAt”).descending()) do { let results = try query.execute() return results.allResults() } catch { print(“查询笔记失败: \(error)”) return [] } } // MARK: - 同步 func startSync(withGatewayURL url: URL, username: String, password: String) { serialQueue.async { [weak self] in guard let self = self else { return } // 停止旧的同步器 self.replicator?.stop() let target = URLEndpoint(url: url) var config = ReplicatorConfiguration(database: self.database, target: target) config.replicatorType = .pushAndPull config.continuous = true config.authenticator = BasicAuthenticator(username: username, password: password) // 只同步属于当前用户的笔记 config.channels = [“user_\(username)”] let repl = Replicator(config: config) let token = repl.addChangeListener { change in // 将状态发布到主线程更新 UI DispatchQueue.main.async { NotificationCenter.default.post(name: .syncStatusChanged, object: change.status) } } self.listenerTokens.append(token) repl.start() self.replicator = repl } } func stopSync() { replicator?.stop() listenerTokens.forEach { replicator?.removeChangeListener(withToken: $0) } listenerTokens.removeAll() } // MARK: - 清理 deinit { stopSync() try? database.close() } }7.3 在 SwiftUI 中集成与响应式更新
在 SwiftUI 视图中,我们可以使用@State和ObservableObject来响应数据变化。
import SwiftUI import Combine class NoteListViewModel: ObservableObject { @Published var notes: [DictionaryObject] = [] @Published var syncStatus: String = “未同步” private var cancellables = Set<AnyCancellable>() private var query: Query? private var queryToken: ListenerToken? init() { setupQueryListener() setupSyncStatusObserver() } private func setupQueryListener() { // 创建 LiveQuery 监听笔记变化 guard let db = DatabaseManager.shared.database else { return } let query = QueryBuilder .select(SelectResult.all()) .from(DataSource.database(db)) .where(Expression.property(“type”).equalTo(Expression.string(“note”))) .orderBy(Ordering.property(“updatedAt”).descending()) self.query = query self.queryToken = query.addChangeListener { [weak self] change in DispatchQueue.main.async { self?.notes = change.results?.allResults() ?? [] } } // 开始监听 query.execute() } private func setupSyncStatusObserver() { NotificationCenter.default.publisher(for: .syncStatusChanged) .receive(on: DispatchQueue.main) .sink { [weak self] notification in if let status = notification.object as? Replicator.Status { self?.syncStatus = “\(status.activity) - \(status.error?.localizedDescription ?? “”)” } } .store(in: &cancellables) } func deleteNote(withId id: String) { DatabaseManager.shared.deleteNote(id: id) // LiveQuery 会自动触发更新,notes 数组会刷新 } } struct NoteListView: View { @StateObject private var viewModel = NoteListViewModel() var body: some View { NavigationView { List(viewModel.notes, id: \.id) { note in VStack(alignment: .leading) { Text(note.string(forKey: “title”) ?? “无标题”) .font(.headline) Text(note.string(forKey: “content”) ?? “”) .font(.body) .lineLimit(2) } } .navigationTitle(“笔记”) .overlay( VStack { Spacer() HStack { Spacer() Text(viewModel.syncStatus) .font(.caption) .padding(8) .background(Color.gray.opacity(0.2)) .cornerRadius(5) .padding() } } ) } } }这个简单的例子展示了如何将 Couchbase Lite 的数据库操作、实时查询和同步状态,与 SwiftUI 的声明式 UI 和响应式编程模型结合起来,构建一个真正离线优先、体验流畅的应用。
本文还有配套的精品资源,点击获取