我之所以想把“Flutter for OpenHarmony 二手物品置换 App”这个标题拿出来专门写一篇实战记录,是因为本地存储这一层看似简单,实际动手时却坑点密集。尤其当你同时面向 Android 和 OpenHarmony 两个平台做适配的时候,存储路径的差异、插件兼容性、数据库升级策略这些细节,会一个接一个地冒出来。我花了一个周末把整个链路从环境搭建跑到真机验证,过程中踩了不少跟“常识”不一样的地方,特意整理出这篇带完整代码和思路的实操文章,适合刚接触 Flutter 跨平台开发、或者正准备往 OpenHarmony 上迁移 Flutter 应用的人做参考。
1. 整体设计思路:存储层该怎么拆
1.1 分清“结构化数据”和“非结构化数据”
二手物品置换 App 的存储需求,如果一上来就直接写 SQLite,很容易把图像这种二进制大对象也硬塞进去。我在开工前先画了一张数据分类表,把要存的东西分成三类:
- 结构化数据:物品信息、用户资料、置换记录、收藏关系,这些有明确的字段和关联关系,适合进数据库。
- 轻量状态数据:登录状态、最近浏览的筛选条件、用户 ID 等,适合键值对存储。
- 二进制文件:物品图片、用户头像,这些应该走文件系统,数据库里只存路径。
这个分类看起来很基础,但直接决定了我后面选插件的方向。如果当初不分清楚,很可能会出现“把图片 base64 后存进数据库”这种事后想骂人的设计。图片这种大字段放进 SQLite 不是不行,但查询列表时要把整个 BLOB 读出来,性能开销非常难看,而且数据库文件会迅速膨胀到几百 MB。把路径存进数据库、文件放沙箱目录,才是最贴近系统和业务双需求的方案。
1.2 为什么用 SQLite 而不是 JSON 文件
很多 Flutter 新手面对“本地存储”时,第一反应是“我用 File 写个 JSON 不就行了”。确实,对于纯展示型的小应用,JSON 文件在简单场景下完全够用。但二手物品置换 App 的核心查询链路是“按分类筛选商品”“按用户查看发布列表”“统计我的收藏数量”,这些操作如果每次都把整个 JSON 文件读进来再过滤,一旦数据量到几百条,页面卡顿就会开始出现。再加上置换记录的关联查询(这张图片属于哪个用户发布、这个用户和谁置换过),JSON 的嵌套结构处理起来会写得像意大利面条。
SQLite 的关系模型天然适合这类业务。sqflite 这个插件在 Flutter 生态里已经非常成熟,而且 OpenHarmony 社区对它有适配,不需要为了鸿蒙单独换一套存储方案。这让我在方案选型上省了很多心,也给了我把更多精力放在业务逻辑上的空间。
1.3 存储层设计的边界划分
我还做了一件事:把存储层和 UI 层彻底解耦。UI 里不允许直接出现db.query,所有数据库操作都收敛到一个DBHelper里。这样做的直接收益是,当我需要把本地方案扩展到网络备份、多端同步时,只需要替换或扩展DBHelper的接口,业务页面一行都不用改。一个小型 App 可以不用这么严谨,但这个边界划分能在项目成长阶段省掉大量重构成本。
另外,所有文件操作统一收敛到一个FileStorageHelper,专门处理图片的保存、复制、删除和路径解析。两个平台(Android 与 OpenHarmony)的路径规则不一样,收敛之后就只有一个地方需要关心差异。下面进入实操环节。
2. 开发环境准备:Flutter、Android SDK 与 OpenHarmony SDK
2.1 Flutter 版本与 OpenHarmony 适配现状
开始写代码之前,环境对齐永远是最重要的一步。我这里说的环境,不是“装个 Flutter 就能跑”,而是 Flutter、Android SDK、OpenHarmony SDK、JDK 四者版本之间要形成一条能跑的链路。我目前用的是 Flutter 3.x 稳定版,OpenHarmony 的 Flutter 适配层可以通过社区维护的 SDK 来支持,基本能覆盖常用的插件生态。
Flutter 版本别用太旧的。早期版本对 OpenHarmony 的支持非常有限,很多新插件跑不起来。建议直接装最新稳定版,然后在命令行执行flutter doctor检查一遍。看到 Flutter、Android toolchain 都没有红色告警,再继续往下走。如果flutter doctor里看到 OpenHarmony 相关项,也能顺便确认适配环境是否被正确识别。
2.2 Android SDK 的安装与配置细节
Flutter 在 Android 侧的构建依赖 Android SDK。很多人觉得 Android SDK 装完就完事了,其实还有几个容易被忽略的点。我在准备环境时,专门确认过以下内容:
- 安装 platform-tools、build-tools、platforms 这几个核心组件,缺一不可。
local.properties文件里要能正确指向 SDK 路径,或者通过ANDROID_HOME环境变量让 Flutter 自动识别。- 保证命令行能执行
adb。很多插件调试和真机部署都要依赖 adb,如果命令行里找不到adb,后面跑真机会很痛苦。
我习惯用 Android Studio 自带的 SDK Manager 来管理 SDK 版本。这样路径统一,不容易出错。装好后在项目根目录执行flutter doctor --android-licenses把许可协议全部接受掉,省得构建到一半弹提示导致失败。
2.3 OpenHarmony SDK 与 DevEco Studio 的配合
要跑 OpenHarmony 真机,光有 Android SDK 还不够,需要安装 DevEco Studio 以及对应的 OpenHarmony SDK。DevEco Studio 是 OpenHarmony 应用开发的主 IDE,它会自动帮你管理 OpenHarmony SDK 和工具链。我的建议是安装 DevEco Studio 的时候选择完整安装,把 SDK 组件都拉齐,后面在工程里配置路径时省事很多。
在 DevEco Studio 里打开现有 Flutter 工程后,要确保工程能识别到 OpenHarmony SDK 路径。如果工程设置里 SDK 路径为空,构建会直接报错找不到依赖。实际项目中我第一次跑的时候,就是忘了设置 SDK 路径,结果构建失败提示都没看懂是什么意思。
2.4 JDK 版本与 OpenHarmony 构建的关系
很多人忽略 JDK 版本对构建链路的影响。Android 侧,AGP(Android Gradle Plugin)和 Gradle 对 JDK 版本有硬性要求,JDK 版本低了会出现编译报错。OpenHarmony 侧,DevEco Studio 自带 JRE,但命令行构建工具同样需要 JDK。我建议统一安装 JDK 17,并且设置好JAVA_HOME,这样两边都能兼顾。
Mac 用户还要特别留意:DevEco Studio 是区分 Intel 和 Apple Silicon 版本的,安装包别下错了。Apple Silicon 机器上如果跑 x86 版本的 DevEco,性能和兼容性都会有问题。这一点我在朋友的机器上帮他踩过坑,重新安装了 arm64 版本才正常。
3. 本地存储方案选型:为什么是 SQLite 而不是文件
3.1 二手物品置换 App 的数据模型
设计数据模型时,我画了一下这个 App 的核心业务:用户有多个物品,物品可以被其他用户收藏,用户之间产生置换记录。所以最基础的四张表就是:用户表、物品表、收藏表、置换记录表。
物品表是核心,字段包括:ID、所属用户 ID、标题、描述、图片路径、分类、预期置换的物品描述、发布时间、状态(在售/已换/下架)。这些字段在后面写 SQL 时会频繁用到,尤其是“按分类筛选”“按状态过滤”这类场景,直接走 SQL 的条件查询效率很高。
用户表存昵称、头像路径、简介、信用评分、注册时间。收藏表只存用户 ID 和物品 ID 两个字段,做唯一约束防止重复收藏。置换记录表则记录了交换双方的用户 ID、物品 ID、时间以及状态。
3.2 对比三种本地存储方案的取舍
我一开始把候选方案列了三条:SharedPreferences、文件存储、SQLite。简单画了个对比表:
| 方案 | 适合场景 | 局限 | 结论 |
|---|---|---|---|
| SharedPreferences | 保存用户偏好、登录状态等轻量键值对 | 数据量大时性能差,不适合复杂查询 | 辅助使用 |
| 文件存储(JSON) | 备份数据、导出导入 | 查询和更新需要全量读写,容易出错 | 数据备份时用 |
| SQLite | 结构化业务数据的增删改查和关联查询 | 需要维护表结构升级 | 主力存储 |
实际项目里我没有只用一种方案。登录状态和筛选条件这类轻量数据用shared_preferences插件保存,物品和用户等核心数据进 SQLite,图片文件单独放磁盘。这么做的好处是每种存储都在它舒服的领域发挥价值,性能和数据可靠性都能兼顾。
3.3 图片等二进制文件的存储位置
图片文件的存储路径,我采用了path_provider插件提供的应用文档目录(Application Documents Directory)。这个目录在不同平台上有不同的物理位置,但插件会帮你封装好。我把图片统一存到一个images子目录里,文件名带上物品 ID 和时间戳,避免重名。数据库里只记完整的文件路径,展示时通过Image.file(File(path))直接渲染。
有一点需要专门提醒:OpenHarmony 的文件沙箱机制和 Android 略有不同,但路径获取方式基本一致,path_provider在 OpenHarmony 适配层有对应的实现。如果你之前只写过 Android 原生应用,可能习惯直接用getExternalFilesDir,到了 Flutter 这边请忘掉那个 API,统一走 path_provider。
4. 数据库设计与建表实操
4.1 四张核心表的结构定义
单独建一个database_helper.dart文件,专门负责数据库打开、版本管理和建表。我用 sqflite 的openDatabase方法,指定数据库版本号 1,然后在onCreate回调中执行建表 SQL。
users 表:
| 字段 | 类型 | 说明 |
|---|---|---|
| id | INTEGER | 主键 |
| nickname | TEXT | 昵称 |
| avatar_path | TEXT | 头像路径 |
| bio | TEXT | 简介 |
| credit_score | INTEGER | 信用评分 |
| created_at | INTEGER | 注册时间戳 |
items 表:
| 字段 | 类型 | 说明 |
|---|---|---|
| id | INTEGER | 主键 |
| user_id | INTEGER | 发布者 ID |
| title | TEXT | 标题 |
| description | TEXT | 描述 |
| image_path | TEXT | 图片路径 |
| category | TEXT | 分类 |
| condition | TEXT | 新旧程度 |
| want_exchange | TEXT | 期望置换物品 |
| created_at | INTEGER | 发布时间戳 |
| status | INTEGER | 0在售 1已换 2下架 |
收藏表和置换记录表结构比较简单,收藏表存user_id和item_id,置换记录表存user_id_a、user_id_b、item_id_a、item_id_b、status、created_at。
4.2 用 sqflite 创建数据库并完成建表
下面是database_helper.dart的核心代码。我保留了事务升级的入口,这一点后面会专门展开讲:
import 'package:sqflite/sqflite.dart'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; class DatabaseHelper { static final DatabaseHelper _instance = DatabaseHelper._(); Database? _db; DatabaseHelper._(); factory DatabaseHelper() => _instance; Future<Database> get database async { if (_db != null) return _db!; _db = await _init(); return _db!; } Future<Database> _init() async { final dir = await getApplicationDocumentsDirectory(); final dbPath = p.join(dir.path, 'secondhand_app.db'); return openDatabase( dbPath, version: 1, onCreate: (db, version) async { await db.execute(''' CREATE TABLE users ( id INTEGER PRIMARY KEY AUTOINCREMENT, nickname TEXT, avatar_path TEXT, bio TEXT, credit_score INTEGER DEFAULT 100, created_at INTEGER ) '''); await db.execute(''' CREATE TABLE items ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER, title TEXT, description TEXT, image_path TEXT, category TEXT, condition TEXT, want_exchange TEXT, created_at INTEGER, status INTEGER DEFAULT 0 ) '''); await db.execute(''' CREATE TABLE favorites ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER, item_id INTEGER ) '''); await db.execute(''' CREATE TABLE exchange_records ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id_a INTEGER, user_id_b INTEGER, item_id_a INTEGER, item_id_b INTEGER, status INTEGER DEFAULT 0, created_at INTEGER ) '''); }, onUpgrade: (db, oldVersion, newVersion) async { // 预留数据库升级逻辑 }, ); } }4.3 发布物品的写入流程:从页面表单到数据库
发布物品是本 App 最核心的写入场景。我在ItemRepository里封装了createItem方法,接收一个Item对象,先写入数据库,然后处理图片拷贝,最后更新记录里的图片路径。
这一步的典型错误是“先保存图片,再拿路径去插入数据库”。如果图片保存失败,数据库就不会有记录;但如果先插入数据库,再保存图片失败,就会留下一条没有图片的脏数据。所以我建议先插入数据库拿到物品 ID 和记录 ID,再用这个 ID 命名图片文件,保存成功后再 update 数据库的图片路径。
4.4 用事务保证批量数据的原子性
初始化应用时,我往往会往数据库里写一批演示数据(比如推荐物品列表)。批量写入如果不包事务,中间任何一条失败都会留下半截数据。sqflite 的事务 API 用起来很简单,把多条 insert 放在transaction回调里,要么全部成功,要么全部回滚。
Future<void> batchInsertDemoItems(List<Item> items) async { final db = await DatabaseHelper().database; await db.transaction((txn) async { for (final item in items) { await txn.insert('items', item.toMap()); } }); }这里有个细节:事务期间如果执行耗时操作(比如网络请求或者文件 IO),数据库锁持有时间过长会影响其他并发读写。所以事务内部尽量只做数据库操作,耗时的图片拷贝放到事务外面。
5. 查询与展示:让本地数据驱动 UI
5.1 物品列表的流式查询与筛选
首页的物品列表,我希望支持按分类筛选和按在售状态过滤。sqflite 的query方法支持传where字符串和whereArgs参数列表,这比拼 SQL 字符串更安全,也能避免 SQL 注入问题。
Future<List<Item>> getItems({String? category, int? status}) async { final db = await DatabaseHelper().database; final conditions = <String>[]; final args = <Object?>[]; if (category != null && category.isNotEmpty) { conditions.add('category = ?'); args.add(category); } if (status != null) { conditions.add('status = ?'); args.add(status); } final result = await db.query( 'items', where: conditions.isEmpty ? null : conditions.join(' AND '), whereArgs: args.isEmpty ? null : args, orderBy: 'created_at DESC', ); return result.map(Item.fromMap).toList(); }5.2 详情页按 ID 查询与图片展示
详情页逻辑最简单:传入物品 ID,按主键查一条记录。查到之后,用Image.file(File(widget.item.imagePath))展示图片。但图片路径如果因为某些历史原因存了相对路径,拼接时要用p.join(applicationDocumentsPath, imagePath)处理,不能直接把相对路径字符串丢给 File。
5.3 收藏列表的分页加载与排序
收藏表数据量会随着用户活跃增加,所以我在收藏列表里做了分页。每次加载 20 条,滑动到底部时再加载下一页。分页查询要在 SQL 里加LIMIT和OFFSET:
Future<List<Item>> getFavoriteItems(int page, {int pageSize = 20}) async { final db = await DatabaseHelper().database; final offset = (page - 1) * pageSize; final result = await db.rawQuery(''' SELECT items.* FROM favorites INNER JOIN items ON favorites.item_id = items.id WHERE favorites.user_id = ? ORDER BY favorites.id DESC LIMIT ? OFFSET ? ''', [_currentUserId, pageSize, offset]); return result.map(Item.fromMap).toList(); }这里注意rawQuery里的LIMIT ? OFFSET ?,有些旧版本 SQLite 不支持在占位符里传限制参数,遇到这种情况可以改用在 SQL 里拼整数的方式,但要保证传入的一定是 int 类型值。
6. 在 OpenHarmony 上运行 sqflite 的实战要点
6.1 插件适配情况与 pubspec 配置
很多人的第一反应是“sqflite 这么依赖 Android/iOS 原生通道的插件,OpenHarmony 上能跑吗”。我实测下来的结论是:能跑,前提是 Flutter SDK 的 OpenHarmony 适配层版本跟得上。当前 OpenHarmony 适配层对 sqlite 相关插件的支持已经比较完善,直接把sqflite写进 pubspec 依赖,构建时适配层会自动桥接到 OpenHarmony 的 sqlite 实现。
path_provider同样可以正常使用,它会返回应用在 OpenHarmony 沙箱里的文档目录。这个目录对应的是应用私有目录,卸载应用时会一并清除,所以如果需要长期保留的数据,后续一定要做导出备份。
6.2 MissingPluginException 的排查思路
如果在 OpenHarmony 真机上运行时报MissingPluginException,第一反应不要慌,这不一定代表插件不能用。排查顺序建议是:
- 确认 Flutter SDK 版本和 OpenHarmony 适配版本是否匹配,去适配层仓库看下 README 支持的版本号。
- 确认 pubspec 里依赖的
sqflite和path_provider版本不是太老,太老版本可能没有针对 OpenHarmony 的实现。 - 执行
flutter clean后重新构建。偶尔缓存的动态链接库没有更新会导致通道注册失败,构建产物清了多半能解决。
我同事遇到的“真机第一次运行报 MissingPluginException”,最后发现就是flutter clean没跑,缓存里残留了旧平台的插件注册表。
6.3 真机调试时的常见问题:权限与数据库存储路径
OpenHarmony 的应用沙箱机制对文件访问路径有明确限制。如果调试时发现图片加载不出来,先检查是不是路径不在应用沙箱目录内。OpenHarmony 上直接用系统相册图片路径去加载,通常会被隔离策略拒绝。正确做法是把选择到的图片复制进应用文档目录,再用应用私有路径来读取和展示。
还有一点,真机调试时如果修改了数据库表结构,要记得数据库版本号也要升级。我在开发中就是加了字段忘记升版本号,结果 onUpgrade 不触发,老数据和新代码不匹配,点击列表直接崩溃。这个问题排查了半小时才意识到是版本号没变。
6.4 数据库升级与数据迁移的坑
数据库版本从 1 开始,表结构变更时版本号 +1。sqflite 的onUpgrade会拿到oldVersion和newVersion,你可以按需写迁移语句。
onUpgrade: (db, oldVersion, newVersion) async { if (oldVersion < 2) { await db.execute('ALTER TABLE items ADD COLUMN location TEXT'); } if (oldVersion < 3) { await db.execute('ALTER TABLE items ADD COLUMN contact_phone TEXT'); } },我的建议是把升级逻辑写成可叠加的,不要只写“当前版本应该长什么样”。因为用户可能从 v1 直接升到 v3,中间 v2 的迁移逻辑也必须执行。逐版本判断并叠加变更,才能保证升级安全。
7. 常见问题与排查技巧实录
7.1 数据库升级与数据迁移的“隐形地雷”
升级数据库时最怕的就是用户设备上的数据和开发机不同。开发期间你可能反复删库重来,但正式发布的版本数据迁移失败会很影响体验。我踩过的一个坑是:新增字段时用了 SQLite 比较新的语法,但 OpenHarmony 内置的 SQLite 版本比 Android 旧,导致ALTER TABLE执行报错。
排查方式也很基础:在升级前先查一下当前 SQLite 的版本,PRAGMA user_version只是版本号,真正执行PRAGMA database_list看不出太多问题。要稳一点,可以在开发阶段就安装 OpenHarmony 模拟器或者真机做一次迁移测试。
7.2 列表刷新不及时:踩过的一个 UI 同步坑
写完数据库后,页面数据不刷新是 Flutter 新手很容易踩的问题。数据库插入成功后,列表界面还显示旧数据,很常见。这里的原因不是数据库写失败,而是 UI 层没有监听数据变更。我引入了一个简单的ChangeNotifier,数据变化时调用notifyListeners(),模型层和 UI 层做绑定。
如果你的场景更复杂,还可以引入类似sqflite的sqlbrite思路来做响应式查询,但在小项目里没必要,一个 ChangeNotifier 完全足够。
7.3 真机运行报 MissingPluginException 的修复过程
这个问题我在 OpenHarmony 真机上实际遇到过一次。当时的项目是 Flutter 3.16 + 适配层版本不够新,shared_preferences这个插件异常了。处理方法是升级 Flutter SDK 到适配层要求的版本,然后flutter clean后重新编译,问题消失。
所以如果你的插件在 Android 模拟器上没问题,但 OpenHarmony 真机上报 MissingPluginException,优先考虑版本兼容性,不要一上来怀疑插件不支持 OpenHarmony。大部分常用 Flutter 插件在适配层里都有实现。
7.4 一个提高排查效率的习惯:写日志与导出数据库
本地存储的排查难度比 UI 高,因为你看不到数据。我习惯在 Repository 层的读写路径上加日志,记录操作的 key 和影响行数。真机调试时通过 DevEco Studio 的 log 窗口观察,能很直观地定位是写入失败、查询没命中,还是 UI 拿到数据后没有渲染。
如果数据量小,还可以直接把数据库文件导出到本地。把沙箱里的.db文件通过 adb 或 DevEco 文件管理器拉出来,用 SQLite 可视化工具打开检查,比瞎猜高效太多。这种“把存储层打开看”的习惯,能省下很多排查时间。
结尾:再谈一下本地存储的设计心态
做完这个实战项目,我最大的体会是:本地存储不是“把数据存进去”那么简单,它是一套需要在接口设计、数据模型、异常处理、版本兼容等多环节做权衡的系统工程。
一开始我把所有数据都往 SQLite 塞,后来又把所有东西都往文件和 SharedPreferences 里放,折腾下来发现合适才是最好的。关联数据和需要查询过滤的用 SQLite,轻量偏好用 SharedPreferences,图片文件单独存路径,这样的分工既清晰又高效。给数据库留好升级入口和日志路径,哪怕后面真要接入网络同步服务端,底子也都是干净可扩展的。
最后说一个个人习惯:每写一个存储模块,我会顺手把“这个模块在哪条路径上、处理了什么类型的数据、读写失败时该怎么提示用户”一并写进注释。两个星期后回来看这些模块,你会发现付出的这点“额外时间”非常值得。