1. 从项目背景到技术选型:为什么用Flutter做鸿蒙应用
家庭药箱管理这个需求其实一直存在,只是大多数人没太当回事。家里常备药越堆越多,过期药、重复买药、孩子和老人的用药剂量记不清,这些都是实际痛点。我最初本来想用原生ArkTS写一个鸿蒙专用版本,但后来看到Flutter官方社区对OpenHarmony的支持已经走完了相当长一段路,就决定试试用Flutter跨端方案来做。
先说结论:Flutter for OpenHarmony这套方案当前已经能支撑真实业务开发,药品列表这种中低复杂度页面完全没问题。Flutter的跨端能力意味着同一套Dart代码可以跑在Android、iOS、Web以及OpenHarmony上,对于个人开发者或者小团队来说,节省的维护成本非常可观。我在项目初期就把目标定清楚了——先把药品列表做扎实,再逐步扩展扫码录入、过期提醒、用药记录这些模块。
技术选型上,我用的是OpenHarmony的RK3568开发板做真机调试,配合社区维护的flutter_flutter仓库的OpenHarmony分支。很多人在这一步就卡住了,因为OpenHarmony的设备树选择确实容易让人懵。RK3568在不同开发板上有不同的设备树配置,选错了直接起不来系统。
实际选设备树并没有那么玄乎,核心原则就一条:以你手上开发板对应的厂商适配版本为准。如果你是Dayu200开发板,直接用主线的RK3568设备树配置即可;如果是自定义板卡,就得找厂商提供的补丁包,不要自己瞎改。我在第一次刷机时就因为贪图"最新版本"去选了非适配的设备树,结果系统能启动但触摸屏完全没反应,折腾了半天。后来规规矩矩用官方适配版本,问题立刻消失。
关于Flutter环境的安装与配置,网上的教程很多但普遍存在版本偏差。我建议直接用官方文档的版本管理工具fvm,避免不同项目之间Flutter版本冲突。安装完成后注意一个细节:环境变量PATH改完之后必须开一个新终端窗口才能生效,在旧终端里执行flutter命令会提示找不到。这个坑看起来弱智,但我在VSCode里踩了不下三次。
鸿蒙设备侧的准备工作也不复杂。先在OpenHarmony源码环境下编译出Flutter引擎的so库,然后通过hdc工具连接开发板,把编译产物push到设备上。这个过程中最关键的是确认目标系统的API版本和你的Flutter引擎版本匹配,不匹配的症状通常是应用装上了但页面启动就黑屏,没有任何报错。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 药品列表的数据模型与本地存储设计
药品列表是整个家庭药箱管理App最核心的模块,后续所有功能几乎都要围绕它展开。我的做法是先定义一套清晰的数据模型,再决定存储方案。这一步做不好,后面写任何功能都会束手束脚。
药品的基本属性我分了这几类:通用名和商品名、规格剂量(比如"0.25g*24片")、生产日期和有效期(精确到月份就够了)、库存数量、适用人群(成人/儿童/老人)、用药说明(用法用量和注意事项)、药品照片路径。
dart复制class Medicine {
final String id;
final String genericName;
final String brandName;
final String spec;
final DateTime productionDate;
final DateTime expireDate;
final int stock;
final String audienceType;
final String usageNotes;
final String imagePath;
Medicine({
required this.id,
required this.genericName,
required this.brandName,
required this.spec,
required this.productionDate,
required this.expireDate,
required this.stock,
required this.audienceType,
required this.usageNotes,
this.imagePath = '',
});
bool get isExpiringSoon {
final daysRemaining = expireDate.difference(DateTime.now()).inDays;
return daysRemaining >= 0 && daysRemaining <= 90;
}
bool get isExpired => expireDate.isBefore(DateTime.now());
}
有效期字段我特别用了DateTime而不是简单存字符串,虽然存字符串看着省事,但后面要做"临期药品提醒"功能时就会非常痛苦。有了日期类型之后,isExpiringSoon和isExpired这两个计算属性可以直接在列表页做筛选逻辑,代码干净得多。
本地存储我选了sqflite插件,因为药品数据有结构化查询需求,比如按名称搜索、按有效期倒序排列、筛选临期药品。数据库表结构的设计上,我给有效期字段加了索引,这是很多新手容易忽略的性能细节。药品数量一旦上了几百条,没有索引的查询会明显变慢。
建表语句如下:
sql复制CREATE TABLE medicines (
id TEXT PRIMARY KEY,
generic_name TEXT NOT NULL,
brand_name TEXT,
spec TEXT,
production_date TEXT,
expire_date TEXT NOT NULL,
stock INTEGER DEFAULT 0,
audience_type TEXT DEFAULT 'adult',
usage_notes TEXT,
image_path TEXT
);
CREATE INDEX idx_expire_date ON medicines(expire_date);
关于图片存储,我一开始尝试把药品照片以base64字符串存进SQLite,发现列表加载时明显卡顿,图片多了以后数据库文件膨胀得厉害。后来改成把图片文件放到应用私有目录,数据库中只保存路径,问题就解决了。这个方案对家庭药箱这种本地单机应用来说是最合适的,省掉了文件服务器。
数据库操作层封装我用了一个单例模式,把增删改查的SQL都集中在一个类里管理。不要在UI层直接写SQL,这是我个人的一个硬性习惯。单例加异步操作的组合在Flutter里用起来很顺手,配合sqflite的transaction可以轻松实现批量导入、批量删除这种操作。
3. 药品列表页UI架构与核心实现细节
药品列表页是整个App的门面,用户打开第一眼看到的就是它。我的UI设计思路是:顶部一个搜索框,下面用分类Tab区分全部药品、临期药品、已过期药品,主体部分用ListView展示药品卡片。每张卡片显示药品照片、通用名、规格、库存和有效期状态,点击进入详情页,长按弹出编辑和删除菜单。
页面状态管理我选了Provider而不是Bloc或者GetX,理由很简单:项目规模不大,Provider的代码量最少且容易理解。对于家庭药箱这种中小型应用,没有必要引入重型状态管理库来增加心智负担。
dart复制class MedicineListPage extends StatefulWidget {
@override
_MedicineListPageState createState() => _MedicineListPageState();
}
class _MedicineListPageState extends State<MedicineListPage> {
final MedicineDao _dao = MedicineDao();
late Future<List<Medicine>> _medicinesFuture;
String _keyword = '';
int _tabIndex = 0;
@override
void initState() {
super.initState();
_medicinesFuture = _dao.fetchAllMedicines();
}
Future<void> _refresh() async {
setState(() {
_medicinesFuture = _dao.fetchAllMedicines();
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('家庭药箱'),
actions: [
IconButton(
icon: Icon(Icons.add),
onPressed: _navigateToAddPage,
),
],
),
body: Column(
children: [
Padding(
padding: const EdgeInsets.all(12),
child: TextField(
decoration: InputDecoration(
hintText: '搜索药品名称',
prefixIcon: Icon(Icons.search),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
),
onChanged: (value) {
setState(() {
_keyword = value.trim();
});
},
),
),
Row(
children: [
_buildTabButton('全部', 0),
_buildTabButton('临期', 1),
_buildTabButton('过期', 2),
],
),
Expanded(
child: FutureBuilder<List<Medicine>>(
future: _medicinesFuture,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
return Center(child: Text('加载失败:${snapshot.error}'));
}
final medicines = _filterMedicines(snapshot.data ?? []);
if (medicines.isEmpty) {
return _buildEmptyPlaceholder();
}
return RefreshIndicator(
onRefresh: _refresh,
child: ListView.separated(
itemCount: medicines.length,
separatorBuilder: (_, __) => Divider(height: 1),
itemBuilder: (context, index) {
return MedicineCard(medicine: medicines[index]);
},
),
);
},
),
),
],
),
);
}
}
列表项的UI我单独抽了一个MedicineCard组件,这样页面代码不至于越来越臃肿。卡片左侧放药品图片,没有图片时显示一个药丸形状的占位图标;中间是名称和规格信息;右侧是状态标签。状态标签的颜色逻辑很直观:正常绿色、临期橙色、过期红色。
有一个细节值得提一下:图片加载用的是Image.file而不是Image.asset,因为药品照片是运行时写入的文件。如果你直接混用Image.asset的路径风格去加载本地文件,图片会一直加载不出来且不报错,这个问题我排查了小半天才发现根因。
分类Tab的筛选逻辑我放在了内存里做,而不是每次切换Tab都重新查数据库。因为家庭药箱的数据量级通常在几十到几百条,全量加载之后在Dart层用where条件过滤,性能完全够用,体验也更好。
dart复制List<Medicine> _filterMedicines(List<Medicine> list) {
var result = list;
if (_keyword.isNotEmpty) {
result = result.where((m) =>
m.genericName.contains(_keyword) ||
m.brandName.contains(_keyword)).toList();
}
switch (_tabIndex) {
case 1:
result = result.where((m) => m.isExpiringSoon).toList();
break;
case 2:
result = result.where((m) => m.isExpired).toList();
break;
}
return result;
}
空列表时的占位设计同样重要。药箱里没有药品时会显示一个友好的空状态图标加提示文字,引导用户点击右上角的加号录入第一个药品。这个设计看似不起眼,实际使用中能明显降低用户的上手挫败感。
4. 新增药品页面的表单处理与日期选择
药品列表的数据从哪来?靠用户一条条录进去。所以新增药品页面的体验直接决定了用户愿不愿意持续使用这个App。我在这里花了相当大的精力做表单细节,可以说这个页面的代码量比列表页还多。
表单字段按优先级分成两组:必填项只有通用名和有效期,其他全部可选填。这样设计是为了降低录入门槛,不然用户看到十来个字段直接放弃录入了。当然,后续在使用过程中如果发现信息不完整,可以在详情页补充编辑。
日期选择器是这里的重头戏。生产日期和有效期这两个字段,Flutter自带的showDatePicker足够用,但我加了一个小优化:有效期不能早于生产日期。这个校验逻辑不复杂,但在实际用药场景中非常关键,总有人会把有效期填得比生产日期还早。
dart复制Future<void> _pickExpireDate() async {
final now = DateTime.now();
final firstDate = _productionDate ?? now.subtract(Duration(days: 365 * 5));
final lastDate = now.add(Duration(days: 365 * 5));
final picked = await showDatePicker(
context: context,
initialDate: _expireDate ?? now.add(Duration(days: 365)),
firstDate: firstDate,
lastDate: lastDate,
helpText: '选择有效期',
cancelText: '取消',
confirmText: '确定',
);
if (picked != null) {
setState(() {
_expireDate = picked;
});
}
}
关于日期选择器的主题颜色问题,有一个很常见的坑:showDatePicker弹出的对话框颜色不会自动跟随MaterialApp的theme。有热搜词提到"flutter showlicensepage 页面的主题颜色",其实日期选择器也有类似的独立主题问题。你需要单独给DatePickerTheme设置样式,才能让它和App整体风格统一。
我在录药品照片的时候遇到了Flutter调用鸿蒙系统相册的适配问题。Flutter标准插件image_picker在OpenHarmony上会直接报MissingPluginException。目前社区给出的方案是使用OpenHarmony专门适配的版本,或者在鸿蒙平台上通过Platform Channel桥接到原生选择器。我采用了后者,用MethodChannel写了一个简单的图片选择通道,调用鸿蒙的PhotoViewPicker来完成选图。
表单提交的时候,有一个特别值得注意的交互细节:按钮的加载状态。用户点击保存按钮后,数据库写入可能需要几百毫秒,如果没有任何反馈,用户很容易连续点击导致重复插入。我的做法是点击后立即把按钮置为loading状态,并且禁用整个表单层。
5. 细节打磨:搜索防抖、列表缓存和热重载的坑
这个项目做到中期,我发现自己花了大把时间在处理"列表以外的细节"。这些细节单看都不起眼,但合在一起就是用户体验和专业度的分水岭。
搜索防抖就是一个典型例子。如果用户每输入一个字符就立刻去过滤列表,在数据量大时UI会出现肉眼可见的卡顿。我用了最简单的方案:设置一个300毫秒的延时,用户停止输入后再执行过滤逻辑。注意在每次onChanged触发时先取消之前的延时任务,避免回调乱序。
dart复制Timer? _debounce;
void _onSearchChanged(String value) {
if (_debounce?.isActive ?? false) {
_debounce!.cancel();
}
_debounce = Timer(Duration(milliseconds: 300), () {
setState(() {
_keyword = value.trim();
});
});
}
列表缓存方面,Flutter的ListView默认就会回收不可见的列表项,不用额外配置。但药品的图片加载建议加一层内存缓存,避免用户反复上下滑动时频繁IO。我用的是cached_network_image的思路,但因为图片在本地路径,所以写了简单的ImageCache手动管理逻辑。
项目里还有一个常见的问题是热重载后浏览器没更新。很多人在VSCode里写Flutter OpenHarmony应用时,按了热重载按钮发现界面没变化,就开始怀疑自己代码写错了。实际上这是因为OpenHarmony的调试通道和Flutter的连接不稳定,热重载信号没有正确传到开发板。遇到这种情况,先试键盘的Shift+R强制整页刷新,如果还不行直接重新跑flutter run。我自己的经验是:热重载成功率大约七成,剩下三成只能重启。
Flutter跑在OpenHarmony设备上的性能表现,在我的RK3568上整体流畅度可以接受,但复杂页面首次构建会有明显的白屏期。优化手段有两个方向,一是用DevTools定位性能瓶颈,二是对耗时操作做异步处理。药品列表这种中等复杂度页面,实测帧率能稳定在50fps以上,已经够用了。
关于依赖包下载慢或者各种版本不对导致依赖包下不下来的问题,我简单说一下自己的处理策略。优先使用pub.dev官方源,如果公司网络访问外网不稳定,就配置PUB_HOSTED_URL和FLUTTER_STORAGE_BASE_URL为国内镜像源。下载依赖时注意看具体是哪个包失败了,单独google那个包的错误信息,比自己瞎猜效率高得多。
6. 常见问题速查:鸿蒙设备调试中我踩过的坑
做OpenHarmony应用开发最难受的一点是生态还不成熟,很多报错你在搜索引擎里翻半天都找不到答案。我把这个项目中遇到的高频问题整理成一个速查表,希望能帮大家少走一些弯路。
第一个高频问题是升级Flutter版本后编译报错CMake Error。这个错误在Windows环境下特别常见,根本原因是CMake版本或Visual Studio组件和Flutter要求的版本对不上。解决方案是安装VS2019或VS2022时勾选"使用C++的桌面开发"和"Win10/11 SDK"组件,然后在flutter config里指定正确的CMake路径。OpenHarmony开发环境主要是Linux和Windows两种,Windows下这个坑出现的概率很高。
第二个高频问题是flutter create之后运行的Hello World项目能装上,但换成OpenHarmony设备就黑屏。这个大概率是Flutter引擎so文件没有正确push到设备,或者OpenHarmony系统的API Level和引擎版本不匹配。解决办法是去flutter_flutter的OpenHarmony分支Release页面下载对应版本的引擎产物,用hdc file send命令放到设备/system/lib64/目录下,并确认文件权限是644。
第三个问题是polling机制导致的握手失败。hdc连接的稳定性直接影响flutter run的执行,如果开发板经常休眠,调试会话会断掉。我的经验是长期调试时关闭屏幕自动休眠,并且使用USB有线连接而不是网络连接,延迟和稳定性都好很多。
第四个问题是IAP支付拉起相关的话题,虽然不是列表功能的核心,但很多做商业应用的开发者会关心Flutter能不能兼容鸿蒙拉起IAP支付。目前官方没有完整适配,如果需要接入应用内支付,必须走Platform Channel自行对接鸿蒙的IAP SDK。这个工作量并不小,所以如果只是做家庭自用工具,建议先砍掉支付功能,聚焦核心体验。
还有一个容易忽略的问题是FlutterCheckboxListTile文字距离按钮太近。这是Flutter默认布局导致的,不是鸿蒙特有问题。解决办法是在CheckboxListTile的controlAffinity属性上做调整,或者使用自定义的Row布局替代系统组件。药品列表里的"常备药"标记功能我当时就用到了这个组件,微调间距之后视觉舒服了很多。
7. 药品列表的扩展思考:从列表到完整的药箱管理闭环
药品列表功能做到这一步,目前已经具备基础使用价值。但距离一个真正实用的家庭药箱管理App,还有几条明显可以走深的路。
过期提醒是最值得优先做的扩展。列表页已经有临期和过期的筛选逻辑,剩下的就是定时通知。OpenHarmony上的本地通知能力需要通过Platform Channel调用,Flutter的flutter_local_notifications插件在OpenHarmony上还没有官方适配版本。实现方式需要自己写鸿蒙侧的NotificationHelper,通过MethodChannel暴露给Dart层调用。通知策略上可以做三种:提前7天提醒一次、当天提醒一次、过期后静默标记。
批量录入是另一个提升效率的功能。家庭成员往往有一堆药要录入,逐个手填很累。可以考虑用摄像头扫描药盒上的药品电子监管码,通过国家药品监管码数据库反查药品信息。这个方案在Android上技术已经成熟,OpenHarmony上摄像头调用走Platform Channel也能实现,但扫码库的选择需要重新评估兼容性。
多家庭成员的药箱分组也有做头。家里老人和孩子用药不同,药品可以按成员分组管理,每个成员有自己的用药档案。这个功能对数据模型的改动比较大,但考虑到家庭场景的真实需求,还是值得做的。
微信登录和云同步这一类功能,在当前阶段我个人不推荐加。一是OpenHarmony端微信SDK适配情况不明朗,二是家庭药箱数据其实很敏感,上云之前得先想清楚隐私和安全策略,不然得不偿失。
关于后续的功能规划,我的思路是遵守一个原则:先做本地完整闭环,再考虑云能力。当前版本做到"录药-查药-筛药"三步闭环已经是个合格的最小可用产品了。
顺带分享一个做完列表后我在整体产品上的一点体会:药品列表做得越简单、越直观,用户就越愿意常用。把临期状态和过期状态用颜色区分出来,比弹窗提醒的效果更自然。我实际测试给家人用的时候,他们不用我教就能理解红绿灯式的颜色语义,这就是好的设计。
项目走到这一步,下一步我准备继续完善详情页和扫码录入模块,等到闭环完整之后再考虑发布到鸿蒙应用市场。希望这篇实战记录能对正在做Flutter和OpenHarmony跨端开发的朋友们有所启发,你们的踩坑路,我能帮你省一点是一点。
