ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

OpenHarmony上Flutter本地存储实战:二手置换App的sqflite全链路实现

OpenHarmony上Flutter本地存储实战:二手置换App的sqflite全链路实现
  • 我在 OpenHarmony 设备上跑通整个 Flutter 二手置换 App 的本地存储链路,前前后后花了差不多一周。今天把完整实现思路、表结构设计、DAO 封装、踩坑记录一次性梳理出来,希望能帮后来人省掉一些弯路。

1. 需求拆解与存储方案选型

1.1 二手置换场景下“本地优先”的账要怎么算

很多人在做跨端 App 的时候,第一反应是把数据全部丢到服务端,本地只做展示缓存。但二手物品置换这个场景,跟普通的电商浏览有个很大的区别:用户会在弱网、甚至完全离线的情况下反复操作。地下车库、地铁车厢、老旧小区楼道,这些真实交易发生的地方信号往往都很差。

我做的这个模拟置换 App 最初的服务端接口只承担物品列表和登录鉴权,后来发现两个问题:一是用户发布物品时,填写了七八张图片和一大段描述后,页面一退出就全部丢掉了;二是用户在浏览别人发布的物品时,反复切换分类标签、上下滑动,每次都请求远端接口,流量和延迟都不理想。

所以本地存储不只是一个“缓存层”,它要承担的职责包括:草稿持久化、离线浏览、收藏记录、置换流程中的本地状态机。说白了,本地数据库是 App 在无网状态下的“第二大脑”。这也就意味着,不能拿几个键值对敷衍过去,必须有一个结构化的存储方案。

1.2 三种本地存储方案的对比与最终选择

Flutter 生态里常见的本地存储方案无外乎三种:shared_preferences、sqflite、Hive(或者 drift 这种 ORM 封装)。

先说说 shared_preferences。它本质上是键值对存储,读写极其简单,适合存用户偏好、开关状态、登录 token 这类轻量数据。但如果用它去存一整个物品列表,就会非常别扭,因为你需要自己处理 JSON 序列化、字段变更、列表筛选,稍不注意就是一连串的 key 管理噩梦。

Hive 的性能确实不错,纯 Dart 实现,无需原生依赖,但它的类型安全比较弱,Box 模式用久了字段容易失控,而且对 SQL 风格的关联查询、复杂条件过滤支持有限。置换 App 里需要频繁做“按分类筛选”“按价格区间筛选”“按状态查询”,用 Hive 写起来远不如 SQL 顺手。

最终我选了 sqflite。这个库是 Flutter 社区里 SQLite 的事实标准,SQL 语法成熟、文档丰富,而且 OpenHarmony 上已经有对应的适配版本,底层走的还是系统 SQLite。对二手置换这种数据模型相对稳定、查询模式固定的场景,关系型数据库是最稳妥的选择。

注意:sqflite 在 OpenHarmony 上不能直接使用 pub.dev 上的桌面版实现,需要引入适配过鸿蒙的 fork 或使用 sqflite_common_ffi 配合系统 SQLite。具体兼容细节我在第 4 章展开讲。

2. 数据模型设计与建库建表

2.1 物品信息、置换记录、收藏关注三类实体的字段设计

数据表的设计决定了后续业务逻辑顺不顺畅。我一开始把物品表设计得特别啰嗦,连“物品的新旧成色描述”“买家备注”都单独建表,后来发现查询的时候 join 了一大堆,代码可读性很差。重新梳理之后,核心其实只需要三张表:物品表、置换记录表、收藏表。至于用户信息,因为这套模拟项目不做完整的账号体系,直接用 device_id 做本地归属标识就够了。

物品表是核心,字段设计上需要同时覆盖“发布时收集的信息”和“流转状态”。包括物品标题、物品描述、分类、期望置换的品类偏好(是的,置换场景必须记录用户想换什么,而不是像买卖场景只记录价格)、物品状态、图片路径列表、创建时间和更新时间。

这里有个容易忽略的细节:图片路径。二手置换 App 的图片一般不是一张,而是多张。SQLite 原生不支持数组,我把图片路径统一用 JSON 数组字符串存入文本字段,读取时再用 jsonDecode 拆开。这个方案够用、直观,也便于迁移。

置换记录表记录的是“谁发起置换申请、申请的是哪件物品、联系方式、申请留言、当前状态、创建时间”。收藏表则更简单,只关联物品 ID 和收藏时间,外加一个唯一约束避免重复收藏。

2.2 表结构定义与索引设计

直接放 DDL(SQLite 方言):

CREATE TABLE items ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, description TEXT, category TEXT DEFAULT 'other', want_to_exchange TEXT, condition_value INTEGER DEFAULT 1, images TEXT, item_status TEXT DEFAULT 'draft', created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL ); CREATE TABLE swap_records ( id INTEGER PRIMARY KEY AUTOINCREMENT, item_id INTEGER NOT NULL, applicant_name TEXT, contact_way TEXT, apply_message TEXT, record_status TEXT DEFAULT 'pending', created_at INTEGER NOT NULL, FOREIGN KEY (item_id) REFERENCES items (id) ); CREATE TABLE favorites ( id INTEGER PRIMARY KEY AUTOINCREMENT, item_id INTEGER NOT NULL, created_at INTEGER NOT NULL, UNIQUE(item_id) );

索引方面,我给 items 表的 item_status、category 各建了一个单列索引,给 swap_records 的 item_id 建了索引。原因很简单:物品列表页最常见的动作就是按状态(在售、已完成)或者按分类进行筛选,没有索引在数据量上来之后全表扫描会明显变慢。虽然本地数据库通常也就几千条记录,但流畅是体验的底线。

实操心得:时间字段不要用字符串存,统一用毫秒时间戳 INTEGER。排序、区间筛选、时区转换全部方便,别等到做“最近一周发布”功能时再后悔。

2.3 为什么状态字段要存文本而不是数字

物品状态我用的是 draft、on_sale、trading、completed、off_shelf 这样的字符串,而不是 0、1、2、3。不少人不理解,觉得数字省空间。

我坚持用字符串的原因有两个。第一,可读性。本地数据库经常要手动排查,直接看一条记录就能知道状态是什么意思,不需要翻文档对照数字。第二,状态流转时语义清晰。draft 到 on_sale,trading 到 completed,代码里读出来的是一个有意义的名字,而不是一个需要二次解释的数字。SQLite 本身对文本字段的处理效率并不差,这个取舍在本地存储这个量级下完全是正收益。

3. 存储层实现与核心代码实战

3.1 存储层架构:DatabaseHelper 单例与 DAO 模式

本地数据库的操作如果直接在 Widget 里写 SQL,短期还行,后期会变得没法维护。我参考后端常用的分层思路,在 Flutter 层做了一个轻量级的存储层:

  • DatabaseHelper:负责数据库的打开、创建、版本管理,全局单例;
  • ItemDao / SwapRecordDao / FavoriteDao:分别封装各自表的增删改查;
  • Repository:对 DAO 做聚合,比如“发布物品时同时创建一条初始置换记录”。

DatabaseHelper 在 sqflite 里打开数据库是一个异步操作,第一步需要拿到数据库的路径再 openDatabase。这个路径在 OpenHarmony 上跟 Android 有一点点差异,后面会专门讲。

核心代码:

class DatabaseHelper { DatabaseHelper._private(); static final DatabaseHelper instance = DatabaseHelper._private(); static const _dbName = 'swap_market.db'; static const _dbVersion = 1; Database? _db; Future<Database> get db async { if (_db != null) return _db!; _db = await _init(); return _db!; } Future<Database> _init() async { String path = join(await getDatabasesPath(), _dbName); return openDatabase( path, version: _dbVersion, onCreate: (db, version) async { await db.execute('''CREATE TABLE items (...)'''); await db.execute('''CREATE TABLE swap_records (...)'''); await db.execute('''CREATE TABLE favorites (...)'''); }, ); } }

这个单例的设计有个讲究:不要在 dart 里同时创建多个 openDatabase 连接,SQLite 底层是文件锁,写并发很容易触发 database is locked 的异常。单例 + 共享同一个 Database 对象,天然规避了这个坑。

3.2 物品发布与草稿保存的实现细节

发布流程是二手 App 的起点,也是我把“本地优先”贯彻得最彻底的环节。用户编辑一件物品时,我并不要求他一次性填完所有字段。编辑中途退出的,自动保存为草稿;点“发布”按钮的,状态字段从 draft 变成 on_sale。

草稿保存的代码非常简单,从页面表单组装一个 Item 对象,如果是新建则 insert,如果已有 id 则 update。关键在于 items 表的 created_at 只在首次插入时写入,updated_at 每次修改都要刷新,这样在物品管理页面里才能给出“xx分钟前编辑过”的信息。

Future<int> saveItem(ItemEntity item, {bool publish = false}) async { final db = await DatabaseHelper.instance.db; final now = DateTime.now().millisecondsSinceEpoch; final map = item.toMap(); map['updated_at'] = now; if (item.id == null) { map['created_at'] = now; map['item_status'] = publish ? 'on_sale' : 'draft'; return db.insert('items', map); } else { map['item_status'] = publish ? 'on_sale' : item.status ?? 'draft'; return db.update( 'items', map, where: 'id = ?', whereArgs: [item.id], ); } }

这里有一个值得注意的点:图片路径字段 images 是本地缓存目录下的文件路径,但当设备存储不足或用户清理缓存时,这些路径可能失效。我给图片路径的存储做了一个简单的探测机制,读取记录时检查文件是否存在,不存在的路径在展示层打一个占位图。这个策略虽然是兜底方案,但实际体验上比直接崩溃或者空白页好太多。

3.3 查询、搜索与分页的 SQL 封装

物品列表是整个 App 最频繁的查询场景。我封装了几个常用的 repository 方法:

  • 按分类 + 状态筛选;
  • 关键词模糊搜索(标题/描述);
  • 按价格区间筛选;
  • 综合排序(最新发布/最快成交)。

关键词搜索是最容易写错的地方。SQLite 的 LIKE 默认是大小写不敏感的吗?其实要看字符集和编译选项,在我的测试环境里,中英文混合的 LIKE 匹配会造成一些预期之外的漏匹配。保险起见,我同时匹配 title 和 description,并且对 keyword 做了 trim 处理。

Future<List<ItemEntity>> searchItems({ String? keyword, String? category, List<String>? statusList, int page = 0, int pageSize = 20, }) async { final db = await DatabaseHelper.instance.db; final conditions = <String>[]; final args = <dynamic>[]; if (keyword != null && keyword.trim().isNotEmpty) { conditions.add('(title LIKE ? OR description LIKE ?)'); final kw = '%${keyword.trim()}%'; args.addAll([kw, kw]); } if (category != null && category.isNotEmpty) { conditions.add('category = ?'); args.add(category); } if (statusList != null && statusList.isNotEmpty) { final placeholders = List.filled(statusList.length, '?').join(','); conditions.add('item_status IN ($placeholders)'); args.addAll(statusList); } final whereSql = conditions.isEmpty ? '' : 'WHERE ${conditions.join(' AND ')}'; final limitSql = 'LIMIT ? OFFSET ?'; args.addAll([pageSize, page * pageSize]); final rows = await db.query( 'items', where: whereSql.isEmpty ? null : whereSql, whereArgs: whereSql.isEmpty ? null : args, orderBy: 'updated_at DESC', limit: pageSize, offset: page * pageSize, ); return rows.map((e) => ItemEntity.fromMap(e)).toList(); }

这个分页方案在数据量小于 1 万条时完全够用。OFFSET 分页的深层问题是深分页时性能下降,但本地场景根本不会出现用户一直翻页翻到第几百页的情况,不必为此引入游标分页的复杂度。

3.4 置换流程中的状态机与事务处理

置换不是简单的“发布—下架”,它是双方互动的过程。我定义的状态机如下:

  • draft(草稿):仅发布者可编辑;
  • on_sale(在售):对所有人可见,可被申请;
  • trading(置换中):已有用户申请置换,物品暂时锁定;
  • completed(已完成):双方达成一致,物品下架;
  • off_shelf(已下架):发布者主动下架。

申请置换这个操作涉及两个写操作:往 swap_records 插入一条申请记录,同时把 items 的状态从 on_sale 改成 trading。这两个操作必须放在同一个事务里,否则可能出现“申请记录已插入但物品状态没变”的数据不一致。

sqflite 里事务用db.transaction((txn) async { ... })包住即可。

Future<bool> applySwap({ required int itemId, required String applicantName, required String contactWay, String? message, }) async { final db = await DatabaseHelper.instance.db; return db.transaction((txn) async { final now = DateTime.now().millisecondsSinceEpoch; await txn.insert('swap_records', { 'item_id': itemId, 'applicant_name': applicantName, 'contact_way': contactWay, 'apply_message': message ?? '', 'record_status': 'pending', 'created_at': now, }); await txn.update( 'items', {'item_status': 'trading', 'updated_at': now}, where: 'id = ? AND item_status = ?', whereArgs: [itemId, 'on_sale'], ); }); }

注意这个 update 的 where 条件里带上了item_status = 'on_sale',这样即使两个用户同时申请同一件物品,只有第一个能把状态从 on_sale 改成 trading,第二个申请会更新零行。虽然没有行级锁的显现语义,但在本地单用户场景下已经能达到“乐观锁”的防重复目的。

4. 踩坑记录与排查思路

4.1 路径问题:OpenHarmony 沙箱目录与文档目录

sqflite 在 Android 上默认通过 getDatabasesPath() 拿到的是 App 私有 data 目录里的 database 文件夹。OpenHarmony 对其做了能力映射,但不同能力集设备的表现有细微差别。我最初在一台模拟器上运行,直接报错找不到目录,后来发现需要显式先创建父目录。

Future<String> _getDbPath() async { var baseDir = await getDatabasesPath(); final dbDir = Directory(baseDir); if (!dbDir.existsSync()) { dbDir.createSync(recursive: true); } return join(baseDir, _dbName); }

另外 OpenHarmony 的沙箱目录在某些版本上重启后可能变化,所以不建议把数据库路径硬编码在某个缓存目录,永远通过 API 动态获取。

4.2 异步并发与 UI 线程阻塞

sqflite 的 API 本身就是异步的,但这不代表不会卡 UI。如果列表页一次性从数据库读出几百条物品记录,并且每条记录都要解析图片路径、关联分类名称,主 Isolate 的耗时仍可能突破 16ms 的帧预算。

我的做法是:把“数据库查询 + 数据映射”这个流程用 compute 或 isolate 执行,只把最终的轻量实体列表传回 UI 层。Dart 的 compute 对传参是有要求的,必须是可以跨 isolate 拷贝的简单类型,不能用 Database 对象本身。

实际项目中,我用了一个简单方案:在 repository 层写一个 static 方法执行查询,用 compute 调用,同时限制单次查询不超过一屏的数据量(20 条左右),滚动到底部再取下一页。这样 UI 线程的压力基本可以忽略。

4.3 数据库升级与迁移

本地数据库上线后不会永远不变。我从版本 1 升到版本 2 时就加了一个“物品图片缩略图”字段,此时需要用到 onUpgrade 回调。直接在 onUpgrade 里执行 ALTER TABLE 是可行的,但一定要注意:如果用户跨多个版本升级,比如从 v1 直接升到 v3,每个版本的增量脚本都必须执行,要避免在 onUpgrade 里写针对特定版本的硬编码逻辑。

onUpgrade: (db, oldVersion, newVersion) async { if (oldVersion < 2) { await db.execute('ALTER TABLE items ADD COLUMN thumb_path TEXT'); } if (oldVersion < 3) { await db.execute('CREATE TABLE categories (...)'); } }

数据库的版本变化一定要写明确的变更记录,我吃过亏:改了表结构但忘了递增版本号,导致老用户更新 App 后数据库不迁移,新增字段一直为 null,查了整整半天。

4.4 常见问题速查表

我把实际操作中遇到的高频问题整理成了一张速查表:

问题现象可能原因解决与排查思路
openDatabase 一直超时数据库路径父目录不存在创建目录后再打开,见 4.1
插入数据后查询不到用的不是同一个 db 实例全局单例共享 Database 对象
列表刷新 UI 无变化页面没有监听数据库变更插入/更新后手动通知 ChangeNotifier 刷新
状态字段变更为空升级时没有递增数据库版本修改表结构必须递增 version 并补迁移脚本
LIKE 搜索匹配不到中文编码或空格问题对关键词 trim,并同时匹配多字段
图片路径失效缓存目录被清理读取时探测文件存在性,兜底占位图
数据库写操作异常多个连接并发写入统一走单例,避免重复 openDatabase
真机上查询极慢缺索引全表扫描items 表 status/category 建索引

4.5 一个典型的排查实录:记录能写但读不出来

这个坑有点隐蔽。我在真机调试时发现一个现象:在发布物品之后,回到列表页能看到数据,但杀掉 App 进程重新启动,列表页就空了。

检查了很久,发现原因是我把数据库文件放在了临时目录,而 OpenHarmony 对临时目录的清理策略比较激进,App 一旦退出就可能被系统清理。解决方案很简单:把数据库路径切换到文档目录,而不是临时目录。

日志内容大概是这样的:第一次启动时创建的数据库文件,进程杀掉之后路径消失。我对比了启动前后的目录列表,确认是写了临时目录。

实操心得:凡是需要长期保留的数据,一律存在文档目录或系统指定的应用数据目录。临时目录只适合图片裁剪、导出分享这类一过性文件。

5. 数据备份、恢复与导入导出

5.1 整库备份与恢复的两种姿势

本地数据库有一个天然的痛点:用户换手机或重装 App 后数据怎么办。如果是纯本地优先的 App,不做备份就等于把用户积累的好几十条物品信息全部清零。

我的方案是在设置页提供“备份到本地”和“从备份恢复”两个入口。备份的逻辑很简单:把数据库文件复制到用户可访问的文档目录,恢复时反向复制回来,然后重新打开数据库。

Future<bool> backupDatabase() async { final dbPath = await _getDbPath(); final backupDir = Directory(join(await getExternalStorageDirectory()?.path ?? '', 'backup')); backupDir.createSync(recursive: true); final backupFile = File(join(backupDir.path, 'swap_backup_${DateTime.now().millisecondsSinceEpoch}.db')); await File(dbPath).copy(backupFile.path); return true; }

但直接复制数据库文件有两个问题:一是如果数据库正在写入,复制出来的文件可能不一致;二是恢复时如果新版本的表结构比旧版本多字段,直接覆盖会导致兼容问题。

稳妥一点的方案是通过 SQL 导出 JSON 文本:遍历三张表内容,序列化成 JSON 文件,恢复时先清空表再逐条插入。这个方法牺牲了一些性能,换来的是跨版本兼容性和可读性。二手置换这个场景的数据量不大,全量导出到 JSON 通常也就几十几百 KB,性能损失完全可接受。

5.2 本地数据与服务端同步的边界在哪里

如果后续要接入真正的服务端,那么本地数据库的表设计需要提前预留一个同步字段,比方说每条记录增加server_updated_at或者sync_status。我的经验是:不要把本地表和服务端表设计成一一对应,本地关心的更多是展示和交互,服务端关心的则是账号、鉴权、多端一致性。

在二手置换这个 App 里,最自然的同步粒度是“用户主动操作时全量同步”,比如进入 App 时拉取远端物品列表、发布物品时推送到远端。这个设计下,本地数据库依然承担离线浏览和草稿功能,但数据的最终一致性由服务端保证。

如果你只做本地存储,这一节可以先不实现,但表设计里保留 updated_at 字段无论如何都不会亏。

6. 性能优化与细节打磨

6.1 列表滑动流畅度与图片加载并存测试

数据库查询本身很快,真正的性能瓶颈在图片。二手物品 App 的每件物品都带有 3 到 9 张实拍图,如果列表页直接加载原图,滑动时内存和 IO 都是灾难。

我的优化方案是:数据库里存的是原图路径列表,但在缩略图展示时,读取数据库之后先判断有没有生成过对应缩略图文件,没有就按需生成一个 120x120 的缩略图缓存到 App 的缓存目录。这样列表页只加载小图,详情页才加载原图。

在 OpenHarmony 设备上实测,列表页 20 条数据加图片,用 Image.file 直接加载原图和用缩略图缓存加载,滑动帧率差距接近 20fps。缩略图带来的收益是立竿见影的。

6.2 大数据量写入时的批量事务优化

新手容易犯的一个错误是:循环 100 条数据,每条都单独调用一次 db.insert。每一条 insert 都是一次事务提交,100 次提交带来的磁盘 fsync 开销相当可观。

正确做法是:把这 100 条插入包进一个事务里,哪怕数据量再大,也只需要一次 fsync。sqflite 的事务 API 天然支持这种方式。

Future<void> batchInsertItems(List<ItemEntity> items) async { final db = await DatabaseHelper.instance.db; await db.transaction((txn) async { final batch = txn.batch(); for (final item in items) { batch.insert('items', item.toMap()); } await batch.commit(noResult: true); }); }

实测下来,100 条数据分别插入耗时约 800ms 到 1.2s,放在单事务里压缩到 50ms 以内。

6.3 查询结果缓存与内存抖动

另一个隐患是查询结果的内存抖动。每次从数据库查出来都会 new 出一批对象,如果频繁下拉刷新又频繁切换分类,Dart 的 GC 压力会很大。做法上可以做一个简单的轻量缓存:相同查询条件的最近一次结果直接复用,只有数据库有写入时才清空缓存。

这个缓存粒度不需要精确,因为本地数据库写入不频繁,列表刷新也主要是用户主动动作。

7. 项目结构调整与后续扩展思路

7.1 存储层在整体项目中的模块划分

如果项目里既有本地存储又有网络请求,建议把两者严格分层。我的模拟项目中,目录结构是这样的:

  • lib/pages:页面与 Widget;
  • lib/entities:实体类,与数据库表一一对应;
  • lib/db:DatabaseHelper 与 DAO;
  • lib/repository:业务仓储层,聚合 DAO 调用;
  • lib/services:网络服务与本地存储服务。

这样分层的理由是:UI 层永远不直接接触 SQL 语句,DAO 层永不关系页面状态。后续哪怕把 sqflite 换成 drift,只要 repository 接口不变,UI 层一行都不用动。

7.2 从本地存储延伸到云端同步的设想

二手置换 App 如果做真,势必要加账号体系和云端同步。那时候本地存储的角色会发生变化:从“唯一的存储层”变成“离线缓存 + 本地优先写入”。这个演进过程中,本地表结构需要增加同步状态字段,比如dirty标记需要推送到服务端的变更。

如果你现在就要为未来做打算,可以在 items 表里预留一个sync_status INTEGER字段,0 表示未同步,1 表示已同步,2 表示冲突待处理。当前不做任何逻辑也可以,但表结构设计好了,未来做增量同步时省去一次数据库迁移。

8. 最后一个私人体会

做这个 Flutter for OpenHarmony 二手置换 App 的本地存储实现,让我最大的一个感受是:跨端开发真正难的地方,不是写好一个 Widget,而是把存储、状态、兼容性这些底层的东西处理干净。sqflite 在 Android 和 OpenHarmony 上的行为不完全一致,你不能把安卓上的经验原封不动地搬过来。每一步都要自己跑一遍模拟器和真机,才敢确定方案是稳的。

最后分享一个小技巧:开发阶段给数据库操作加一个全局日志开关,每次执行 SQL 都打点耗时。这个日志会在你性能排查时救你一命,定位“到底是大图问题还是 SQL 本身慢”这类问题,比拍脑袋猜要快得多。

返回列表