这是一篇关于应用本地数据库管理的开发手记,Demo 叫LicenseLedger,任务号LL-1506。示例导入 6 行许可证清单,唯一记录 5 行、重复 1 行,其中 1 行需要人工复核。所有记录、日志、页面截图均为预设的示意内容,与应用市场实际审核、许可证法律结论或真机测试无关。
一、同一份清单导入两遍,怎么反而多了一倍
做三方库梳理时,经常会看到这样一种业务输入:有人从工程依赖列表复制一批组件名和版本号,填完许可证标识后导入本地复核台账。明明是同一份清单,第二次点击导入,页面上的行数却从五条变成十条。测试同学往往会说“按钮点快了”,但根因是数据层根本没有约定“什么叫同一条记录”。如果把重复检测仅放在按钮防抖上,应用重启、离线重试或换一个入口导入都可能重新出问题。
我这次用 LicenseLedger 讲一个很小的合同:库名 + 版本是业务唯一键,用户填写的 SPDX 字符串只是待确认元数据,不作为唯一身份。清单演示包含六行,其中两行实际指向同一组件版本。导入后的页面显示原始 6、唯一 5、重复跳过 1、待人工复核 1、进度 100%,状态写成“待复核”。这不是自动许可证合规判定;只代表应用把待确认数据整理成了可追踪的本地草稿。
为什么不直接拿数组做find?单次会话中那当然也可以。但一旦要跨重启保留结果、按版本搜索、记录复核状态,就需要结构化的数据存储。ArkData 的 RelationalStore 为应用提供数据库、条件查询和结果游标。数据库里的唯一约束承担最后一道防线,应用层先查重只是为了提供“重复被跳过”的友好反馈。
二、先让唯一键明确,再谈插入速度
第一步不是上 SQL,而是规定键的规范化规则。库名统一 trim 后转小写,版本号去掉两侧空格,但不擅自把1.2和1.2.0合并,因为这可能是不同的版本表达。在我们的演示合同里,@scope/name的作用域部分不能随意丢掉。另一种常见做法是把 SPDX 也放进键里,我不赞成:同一版本如果人工改了许可证说明,不应该凭空变成一条新依赖。
记录示例用五个不同库版本及一个重复输入,业务代码只管结构和字符串校验,不会访问远端依赖仓库,更不会替产品判断许可证是否允许商用。下面先定义一个清晰的输入类型和确定性的规范化方法,便于日后放进 Node.js 测试或应用单测里比较。
export interface LicenseDraft { name: string; version: string; spdx: string; } export function libraryKey(item: LicenseDraft): string { const name = item.name.trim().toLowerCase(); const version = item.version.trim(); if (!name || !version || name.includes('|') || version.includes('|')) { throw new Error('名称或版本无效'); } return `${name}|${version}`; } export const DEMO_DRAFTS: LicenseDraft[] = [ { name: 'chart-lite', version: '1.2.0', spdx: 'MIT' }, { name: 'safe-json', version: '2.4.1', spdx: 'Apache-2.0' }, { name: 'ui-kernel', version: '0.9.3', spdx: 'UNKNOWN' }, { name: 'text-tools', version: '3.0.0', spdx: 'BSD-3-Clause' }, { name: 'tiny-math', version: '1.7.2', spdx: 'MIT' }, { name: ' Chart-Lite ', version: '1.2.0', spdx: 'MIT' } ];通过这段纯业务方法,我们能预先推导最后一条和第一条具有同一键chart-lite|1.2.0,因此输入 6 行只有 5 个身份。注意,“UNKNOWN” 被标成待复核并不是数据库报错;它是许可证元数据尚不明确的业务状态。不要把一条库名或 SPDX 字段的字符串比较,写成已经完成了软件合规审核。应用只能提示风险,最终解释需要证据和人工判断。
数据库初始化时我选择表级的PRIMARY KEY,把同一库版本的重复写入约束留在存储层;这样即便另一个按钮绕过了预检查,重复项仍不应成为两条有效记录。示例使用@kit.ArkData的relationalStore获取本地 RdbStore,通过executeSql建表,名称和字段都写成普通 SQL 字符串。SQLite 语义的约束和 HarmonyOS 包装 API 需要分别看待:应用应根据目标 SDK 的实际错误类型细化异常分支,不能只靠 catch 就当作“重复”。
这里不做跨表事务,因为本篇只处理单表的一条一条幂等入库;批次原子替换是另一种问题,不能拿来偷换。本次要解决的是重复导入后的可追踪性、键标准化与查询对象生命周期,而不是数据库迁移或大规模吞吐量压力测试。
import { relationalStore } from '@kit.ArkData'; import { common } from '@kit.AbilityKit'; const DB_CONFIG: relationalStore.StoreConfig = { name: 'license_ledger.db', securityLevel: relationalStore.SecurityLevel.S1 }; export async function openLicenseStore( context: common.UIAbilityContext ): Promise<relationalStore.RdbStore> { const store = await relationalStore.getRdbStore(context, DB_CONFIG); await store.executeSql(`CREATE TABLE IF NOT EXISTS license_item ( lib_key TEXT PRIMARY KEY NOT NULL, name TEXT NOT NULL, version TEXT NOT NULL, spdx TEXT NOT NULL, review TEXT NOT NULL )`); return store; }这里的context必须来自合法的应用组件上下文,数据库实例建议由应用自己的仓储模块统一管理,不能在每个列表项的build()里频繁开库。演示代码暂不涉及多设备协同,也不等同于系统级安全审计。创建表失败应停止导入,而不是把“0 条记录”伪装成成功。正式项目里还要区分磁盘不足、数据库损坏、版本迁移失败等情况,并有可追踪的错误记录。
三、游标不能顺手扔给垃圾回收器
真正需要在代码审查时盯住的是查询结果。RdbPredicates帮应用按lib_key构造查询条件,返回的ResultSet不应该无限期留着。即便查询只看一行,也要在 finally 里 close;否则列表反复进入和大量批次检查叠加后,资源持有问题会比重复数据更隐蔽。
接下来这段实现把“是否已经存在”封装成一次限定字段的查询。插入前先查可以给出跳过数量;但要注意查询与写入之间并不自动构成并发原子操作。如果存在多个导入入口并行运行,仍需串行化仓储入口,并保留 PRIMARY KEY 约束处理竞争写入。本演示通过busy门禁控制当前页面的单批次导入,不将它描述为跨进程并发锁。
export async function existsKey( store: relationalStore.RdbStore, key: string ): Promise<boolean> { const predicates = new relationalStore.RdbPredicates('license_item'); predicates.equalTo('lib_key', key); const rs = await store.query(predicates, ['lib_key']); try { return rs.rowCount > 0; } finally { rs.close(); // ResultSet 与页面状态不是同一种生命周期 } } export async function insertOnce( store: relationalStore.RdbStore, draft: LicenseDraft ): Promise<boolean> { const key = libraryKey(draft); if (await existsKey(store, key)) return false; const review = draft.spdx.trim() === 'UNKNOWN' ? '待复核' : '待确认'; await store.insert('license_item', { lib_key: key, name: draft.name.trim(), version: draft.version.trim(), spdx: draft.spdx.trim(), review: review }); return true; }业务上还要想清楚已经存在的键要不要更新许可证字段。当前实现的答案是“不更新”:一次新导入不能悄悄覆盖之前人工处理过的草稿。需要更新时,应当另开明确的“修改许可证信息”操作并留下审阅记录。这也解释了为什么本篇关注幂等而不是 UPSERT:UPSERT 本身是能力,允许不允许覆盖是产品合同。用户未确认时,越方便的自动覆盖,反而越容易抹掉证据。
使用store.insert时,写库本身可能失败;此时不能把失败计进 duplicate。正确的实现应分为“新增成功”“已存在跳过”“异常中止”三种结果。我们这组静态示意数据没有模拟写库失败,配图也只展示约定成功路径;后续接真实数据库后应为唯一键竞争、磁盘不足等情况分别准备测试,不得将这篇草稿当作覆盖那些边界的证明。
四、让页面只展示结果,不暗中替人审核
在界面层我只显示几个容易验证的数字:导入原始 6 行、入库新增 5 行、重复跳过 1 行、待人工复核 1 行。进度 100% 的含义是“本轮六个输入已得到处理结论”,不是质量通过率,更不是上架成功率。UNKNOWN提示人工处理,其余 SPDX 也只是待确认,不能因为写了 MIT、BSD 等字样就自动授予合规保证。
为了避免用户连续点击按钮发起相同任务,示例在当前页面设置busy,然后串行执行输入,逐条累计inserted和duplicate。仓储方法运行时不依赖页面文本颜色;页面退出后通过 generation 忽略旧批次 UI 回写,数据库已经成功提交的行也不会因为页面离开而自动撤销。这两条生命周期要分开讲:UI 不更新和持久化回滚并不是同一回事。
@Entry @Component struct LicenseLedgerPage { @State busy: boolean = false; @State inserted: number = 0; @State duplicates: number = 0; @State status: string = '待导入'; private generation: number = 1; aboutToDisappear(): void { this.generation++; } async importBatch(store: relationalStore.RdbStore): Promise<void> { if (this.busy) return; this.busy = true; const mine = this.generation; let added = 0; let skipped = 0; try { for (const draft of DEMO_DRAFTS) { if (await insertOnce(store, draft)) added++; else skipped++; } if (mine !== this.generation) return; this.inserted = added; this.duplicates = skipped; this.status = '待复核'; } catch (_) { if (mine === this.generation) this.status = '写入失败'; } finally { if (mine === this.generation) this.busy = false; } } build() { Column({ space: 12 }) { Text('LicenseLedger · LL-1506').fontSize(22) Text(`新增 ${this.inserted} · 重复 ${this.duplicates} · ${this.status}`) Text('样例数据6行,其中UNKNOWN待人工复核1行') }.padding(20) } }上面的importBatch接收仓储层管理的 RdbStore,示例没有把打开数据库和按钮事件接线写成一整套可直接运行的工程;真实项目需要在页面创建前完成openLicenseStore,并把异常反馈给用户。这里还隐藏了一个多次点击后的统计问题:若第二次对同一数据库再次导入,新增将是 0,重复将是 6,而不是永远保持 5 和 1。配图数字对应的是空表上的首次导入演示,不能拿来声称每次运行都会有同样的结果。
我会把关键验收拆成清晰的几种断言:一是两个写法略有差异的chart-lite产生同一个键;二是未知许可证被标为待人工复核,但不会阻断其他行入库;三是游标被主动关闭;四是二次导入不会增加数据库的唯一记录数。真正的数据库测试还要配置测试库并在运行后读取表内容,这一轮没有执行,所以只能给出设计断言,不能冒充实测报告。
五、收束:减少重复写入,不等于完成许可证审查
工具做得越像一个完整工作台,越容易让人把“状态整理完了”误解成“业务审核完成了”。LicenseLedger 只保证示例中的键规则、单页导入门禁和游标关闭路径具有明确的实现位置;至于许可证扫描的准确性、依赖树中的传递依赖、版权声明和法律解释,都不在本篇讨论范围内。后续可以把“待复核”单独做成审核工作流,但那应是新的工程任务,而不是在这一页悄悄增加一个绿色通过按钮。
参考华为官方:RelationalStore ArkTS API 参考;应用数据存储方案选择。接口写法需以实际使用的 SDK 版本为准。本文未进行 DevEco 编译或真机数据库测试,02 图的 IDE 与日志及 03 图的手机内容均为独立绘制的演示界面。