家里老人的药箱慢慢成了个“黑箱”。药是什么时候买的、还剩多少、哪天过期基本靠翻盒子看保质期血糖数据随手记在纸质本子上过两天想对比就得来回翻。我当时就想与其买一个功能固定的智能药盒不如自己写一个App来管这件事。刚好那阵子我在评估 Flutter 跑在 OpenHarmony 设备上的可行性索性把需求做成了一个跨端项目家庭药箱管理App药箱管理加上血糖记录两条主线。迭代了几个版本现在这套东西能在一台通用开发板上稳定运行日常提醒、记录查询、折线趋势都可用。这篇复盘会从整体设计讲到落地实现再把我踩过的坑原样列出来。适合有 Flutter 基础、想做健康管理类工具或者对开源系统应用适配感兴趣的开发者参考。1. 项目整体设计与思路拆解1.1 为什么选择 Flutter而不是其它方案家庭药箱和血糖记录这个场景听起来简单真正做起来绕不开一个底层问题到底在什么平台上跑。家里手机系统不统一有些成员用主流移动系统有些设备比较旧如果每个平台都写一套原生页面光是维护界面状态就够喝一壶的。而我做这个项目的初衷本来也不是为了某个特定系统打造一个商业产品更多是验证 Flutter 在非传统平台上的落地能力。Flutter 在这里最大的优势是 UI 层自绘。它不依赖系统自带的浏览器内核去解释 HTML也不依赖系统控件去渲染组件而是自己把每个像素画出来。这意味着同一个页面在主流移动系统和 OpenHarmony 设备上的表现会非常接近不会出现“样式跑偏”这种跨端常态问题。对于药箱里那种卡片式信息展示字体大小、颜色状态、日期标签这些细节自绘引擎能保证一致性。另外Dart 语言对实体建模很顺手。药品、家庭成员、血糖记录这些数据本身就是典型的对象结构用 class 定义字段、写序列化方法逻辑非常直白。团队也不需要准备两套技术栈一条 Flutter 链路的维护成本比双端原生低很多。这个选择带来的影响从项目一开始就定了调子后续所有页面、图表、存储适配都围绕 Flutter 自身能力来展开数据层尽量少碰平台专属 API插件要选经过移植验证或具备跨端实现的版本。如果某个原生能力绕不过去再单独做一层桥接。事实证明这样约束下来项目结构反而更干净。1.2 功能边界与信息架构我没有把这个应用做成大而全的健康平台那就超出了个人项目该有的体量。整个Demo的信息架构只围绕四个核心模块首页今日概况展示未完成提醒数量、临期药品数量、最近一次血糖数值和趋势摘要。药箱管理药品的新增、编辑、删除、分类、搜索以及有效期状态标签。血糖记录录入数值、测量时间、空腹/餐后/随机分类支持列表和趋势折线图。家庭设置维护家庭成员基础信息所有数据按成员维度隔离。提醒功能挂在药箱管理下面不单独拆出一个“提醒中心”因为提醒的对象就是药品和药品放一起管理更符合直觉。血糖记录完全独立因为它既需要快速录入也需要时间序列展示混在药箱模块里反而会让页面职责不清。信息架构的原则很简单老人的操作路径要短。打开App之后两步之内必须能看到“今天该吃什么药”三步之内必须能完成一次血糖录入。首页今日概况就是为此设计的其他复杂信息往二级页面放不要堆在首屏。1.3 数据模型与关键字段设计数据模型是这次项目里最先动笔、也最需要反复推敲的部分。药箱管理关联药品信息和家庭成员血糖记录也要关联到具体成员所以三张核心表是少不了的。表名关键字段说明family_memberid、name、age、role、emergency_note、created_at家庭成员基础档案role 区分老人、成人、孩子drug_listid、family_member_id、name、generic_name、specification、manufacturer、batch_no、expiry_date、reminder_time、remaining_qty、notes、created_at一条药品记录对应一盒/一瓶实体药blood_sugar_recordid、family_member_id、measure_time、value、unit、sugar_type、note、created_at一条记录对应一次测量unit 标记单位类型字段设计时我留了一些“看似冗余”的信息比如厂商和批号。药品批次用于溯源虽然界面上不一定常显但只要药品出现问题这批药能不能快速找出来就靠这两个字段。剩余数量承担库存提醒的功能药品快用完时首页会给出提示。measure_time 统一用 ISO8601 字符串存储避免不同设备之间时区串台。sugar_type 和 unit 这两个字段我之所以用整数枚举而不用字符串一是为了切换语言和展示文案时方便二是查询时更快。比如“空腹血糖”在界面上显示为中文在数据层就存一个整数 1后面的趋势图逻辑直接根据数值类型分组不需要做字符串匹配。这套模型足够覆盖家庭场景同时又不至于复杂到维护不动。如果要接蓝牙血糖仪只需要在 blood_sugar_record 上增加一个 device_id 字段就能扩展。2. 核心细节解析与实操要点2.1 药品有效期预警别让日期差一天药箱管理的灵魂功能其实就是有效期预警。算法不复杂今天日期对比药品有效期得到剩余天数再按阈值分出“正常”“临期”“过期”三种状态。一开始我的实现非常天真int daysUntilExpiry(DateTime expiry) { final now DateTime.now(); return expiry.difference(now).inDays; }可实际跑起来就出问题了。DateTime.now() 带有时分秒而 expiry 如果来自用户选择的日期通常是当天的 0 点。两者做差只要有效期当天还没到 24 点difference 就会小于整天数inDays 向下取整之后会少一天。比如有效期是 5 月 10 日现在是 5 月 10 日早上 10 点差值是 0.4 天代码会把它判断成“今天已经过期”。正确的做法是先截断时间只保留年月日int daysUntilExpiry(DateTime expiry) { final now DateTime.now(); final today DateTime(now.year, now.month, now.day); final expiryDay DateTime(expiry.year, expiry.month, expiry.day); return expiryDay.difference(today).inDays; }状态阈值也让用户自己设。普通口服药我默认 30 天内算临期但胰岛素这类对存储条件敏感的药品30 天显然太长。所以阈值做成了药品详情里的一个配置项默认值 30用户按需调整。注意日期计算必须尊重“当天不算过期”的原则。很多药其实写的是“有效期至”意味着这个月最后一天还能用下个月第一天才算过期不能简单用月份相减。2.2 血糖记录的单位与参考范围血糖记录比药箱更敏感因为它直接关联到健康判断。项目里我只做记录和可视化不自己做诊断。实测录入时最常见的坑是单位混淆。国内习惯用 mmol/L部分进口血糖仪显示 mg/dL两者相差 18 倍。用户如果算错单位输入一个 126其实对应的可能是 7.0 mmol/L但被当成 126 mmol/L 就成了极其夸张的高值。换算关系1 mmol/L 约等于 18 mg/dL。项目录入时的方案是让用户明确选择单位App 不自动换算因为换算会引入误读。展示层统一按用户偏好显示但底层存储保持原始值和单位字段。一般参考范围我放在录入页面的提示区空腹 3.96.1 mmol/L餐后 2 小时小于 7.8 mmol/L随机一般参考 3.911.1 mmol/L。这些只是科普区间页面标注了“具体请咨询医生”。App 最多做超范围高亮比如数值超过 11.1 时在列表和趋势图上用醒目颜色标出但不做文字判定。数据校验同样重要。录入时值必须在 0.533.3 mmol/L 之间超出这个物理合理范围直接拒绝保存防止误触导致脏数据污染趋势。2.3 用药提醒别用 Timer 硬撑第一次做提醒功能的时候我天真地以为在主页面放一个 Timer 定时器就能实现。结果 App 一退后台Timer 就被系统挂起提醒自然泡汤。正确思路是走系统通知服务。把提醒时间注册到系统调度里到点由系统发送通知和 App 是否在前台无关。这里需要注意几个点通知权限必须先申请未授权时默认在应用内用 banner 提示不直接丢弃。通知通道在部分系统上需要单独创建不同系统对通道名称和重要性级别的要求不太一样。提醒时间的存储格式建议用“时:分”字符串比如 08:30不要存完整 DateTime否则换设备、换时区会出现不可控偏移。实际做的时候我把提醒拆成了两层。第一层是系统级通知由本地通知插件注册第二层是页面级兜底当日志和首页展示时读取当天所有 reminder_time按时间排序生成“今日提醒列表”。这样即使系统通知因为权限被用户关闭打开 App 也依然能看见今天有没有药要吃。2.4 老人友好的交互细节家庭药箱的核心用户其实是老人交互设计不能按年轻开发者的审美来。项目里几个关键约束药品卡片是大字号卡片布局药名和剩余数量突出显示有效期状态用文字标签加底色不只靠颜色区分。主要操作按钮高度不低于 48 逻辑像素录入表单一次只让用户完成一个主操作避免弹窗套弹窗。语言上用“还有 15 天过期”这类自然表达不用“剩余有效期 15D”这种程序员语言。列表排序固定为“过期 临期 正常”把最危险的信息放在最上面。所有排序逻辑全局统一。我不建议在这个阶段堆动画和炫技交互。老人操作时最怕页面突然变化动效越少越不容易误操作。3. 实操过程与核心环节实现3.1 工程初始化与依赖清单工程搭建本身不复杂重点是依赖选型。我的 pubspec.yaml 核心部分长这样dependencies: flutter: sdk: flutter provider: ^6.0.0 path: ^1.8.0 sqflite: ^2.2.0 intl: ^0.18.0 permission_handler: ^11.0.0 flutter_local_notifications: ^15.0.0这里 sqflite 负责本地关系数据库provider 负责状态管理permission_handler 处理相机和通知权限intl 负责日期格式化。需要说明的是sqflite 在 OpenHarmony 设备上要确认对应的移植版本可用我当时用的是一家社区维护的适配分支插件本身做了 scale 渲染兼容页面布局没有出现偏移。工程目录先按标准 Flutter 模板创建再把适配层单独抽出来放一个 core 目录不做花哨的抽象。3.2 三大实体落地 SQLiteSQLite 的优势在于关系查询。药品、家庭成员、血糖记录之间都有外键关联。在 hiv 式 NoSQL 存储里做这种查询会比较绕所以直接上 SQL。建表脚本如下CREATE TABLE family_member ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, age INTEGER, role INTEGER DEFAULT 0, emergency_note TEXT, created_at TEXT NOT NULL ); CREATE TABLE drug_list ( id INTEGER PRIMARY KEY AUTOINCREMENT, family_member_id INTEGER NOT NULL, name TEXT NOT NULL, generic_name TEXT, specification TEXT, manufacturer TEXT, batch_no TEXT, expiry_date TEXT, reminder_time TEXT, remaining_qty INTEGER DEFAULT 0, notes TEXT, created_at TEXT, FOREIGN KEY (family_member_id) REFERENCES family_member(id) ); CREATE TABLE blood_sugar_record ( id INTEGER PRIMARY KEY AUTOINCREMENT, family_member_id INTEGER NOT NULL, measure_time TEXT NOT NULL, value REAL NOT NULL, unit INTEGER DEFAULT 0, sugar_type INTEGER DEFAULT 0, note TEXT, created_at TEXT, FOREIGN KEY (family_member_id) REFERENCES family_member(id) );特别说一下为什么不用纯文件存储。血糖记录经常要按成员和时间范围做过滤查询比如“这个月所有餐后血糖”SQL 一句 WHERE 子句就搞定文件存储就得把全量数据读出来再在内存里过滤数据量大了之后性能和代码复杂度都不划算。数据库初始化放在 Application 启动阶段建表后用事务包裹防止中途失败留下半张表。日常增删改查通过统一的数据库封装类执行每个服务方法对应一个核心操作。FutureListDrugItem queryDrugsByMember(int memberId) async { final db await DatabaseHelper.instance.database; final rows await db.query( drug_list, where: family_member_id ?, whereArgs: [memberId], orderBy: expiry_date ASC, ); return rows.map(DrugItem.fromMap).toList(); }3.3 手写折线图完成血糖趋势血糖记录的价值在于趋势趋势需要通过折线图呈现。我一开始想直接引入现成的复杂图表库后来考虑到 OpenHarmony 适配的不确定性干脆用 CustomPainter 手写了一个轻量折线图只画坐标轴、折线和数据点。核心算法是坐标映射。先求数据集合的最大值和最小值留出上下边距再把每个数值映射到画布的高度区间final dx startX i * stepX; final dy chartHeight - (value - minValue) / (maxValue - minValue) * chartHeight;maxValue 和 minValue 必须先确定否则图形会整体偏移或者贴边。数据点少于两个时直接显示“数据不足”不要画一条难看的线。折线图上我还叠加了一个参考线用虚线标出 7.8 mmol/L 的位置这是一个常见的餐后参考值。记录点超过参考线时点的颜色加深并带一个外圈提示。整个图表不到两百行代码但视觉和交互完全可控。3.4 条码识别与手动兜底录入药品如果全部手工录入每次要敲七八个字段真的很劝退。我加了一个扫码入口相机识别药盒条形码之后先解出条码号再尝试匹配预设数据库。匹配不到就自动跳转手动录入并把条码号预先填进 batch_no。这里有个现实问题不同来源的药品数据更新频率和准确率参差不齐。所以我把匹配结果当成“预填建议”而不是“权威数据”用户仍然可以修改每项内容。界面设计上扫码按钮放在药箱页右上角整个流程控制在三步内扫码、确认、保存。OCR 识别药品说明书我试过一版准确率在复杂版面上不太理想加上离线识别模型体积很大最终果断砍掉。这个决定释放了大量开发精力也让包体积保持在可控范围。健康工具的核心是记录准确识别只是提效手段不能为了提效牺牲数据可信度。4. 常见问题与排查技巧实录整个开发过程中我遇到最多的是下面几类问题整理成一张速查表现象可能原因排查思路与处理建议构建产物无法安装或启动崩溃依赖版本与目标系统不匹配核对工具链版本和最低 API 级别抓取第一个异常堆栈逐帧分析日期显示比实际少一天DateTime 序列化时未统一时区统一用 ISO8601 字符串存储展示时显式转成本地时区通知提醒到点不响权限未开或系统省电策略介入检查权限状态引导用户关闭对该应用的系统限制应用内保留今日提醒列表兜底血糖折线图数据点集中在一侧查询结果未按时间排序SQL 增加 ORDER BY measure_time ASC中文字体在部分设备变成方块字体资源配置缺失显式配置中文字体资源或在主题里声明字体栈展开说几个典型的案例。日期偏差这个问题我在真机演示时才发现某盒药的有效期显示为“还有 0 天”其实当天还能用列表里却标了“已过期”。排查之后确认原因就是前面提到的时分秒差。修复后我把所有 DateTime 的存取都抽成了一个日期服务统一走 ISO8601 字符串转换之后这个 bug 再没出现过。通知问题更隐蔽。真相机上首次安装时权限弹窗被系统拦截了用户没允许就退出后续注册通知时插件返回成功但系统根本不会弹。排查时我先在设置里确认通知权限然后在依赖层级里检查插件是否要求在主线程初始化。最后我在应用内加了一个“提醒自检”页面可以一键触发一条测试通知把问题从“用户看不见提醒”变成“可以自己验证提醒链路”大大降低了排查难度。血糖数据异常也踩过一个坑。某次测试连续录入几十条数据折线图突然变成一条竖线。原因很简单所有记录的时间完全相同图表按索引均匀分布时相同索引位置堆叠导致视觉上像竖线。修复方式是 x 轴按真实时间戳映射而不是按记录序號均匀分布数据点之间自然拉开间距。另一个容易被忽视的问题是权限申请顺序。项目同时涉及相机和通知权限如果两个弹窗连续弹出系统会屏蔽后续请求。处理方法是分段申请进入扫码页面时申请相机权限进入设置页时申请通知权限不在首屏一次性索取。5. 工程组织与开发效率复盘5.1 目录结构决定可维护性项目开发到中期拼单个文件的路子已经走不通了。最终目录结构如下lib/ main.dart app.dart core/ database/ notifications/ models/ drug_item.dart blood_sugar_record.dart family_member.dart pages/ home/ drug/ blood_sugar/ member/ widgets/ status_tag.dart section_card.dart services/ drug_service.dart blood_sugar_service.dartmodels 层只定义实体和序列化逻辑services 层封装数据库查询和通知注册pages 层只负责界面组装widgets 放可复用组件。我个人的体会是不要在设计阶段就过度抽象接口但目录必须先按功能切开否则后面每隔几天就要调整文件边界。5.2 状态管理从 setState 到 Provider 的转变项目初期页面少一个 setState 能应付。到了血糖列表、详情页、趋势图共享同一份数据时setState 的短板就暴露了任何一个页面修改数据其他页面都得手动刷新还要处理各种回调嵌套。后来我切到 Provider同一个数据容器被多个页面监听数据变更点唯一。class BloodSugarNotifier extends ChangeNotifier { ListBloodSugarRecord _records []; ListBloodSugarRecord get records _records; Futurevoid loadRecords(int memberId) async { _records await BloodSugarService.instance.queryByMember(memberId); notifyListeners(); } }页面上使用 Consumer 包裹具体组件只有数据变化时局部重建。这个改动之后血糖录入页保存一条记录列表页和趋势图同时更新不再需要手动传值。5.3 沉淀可复用组件健康类应用有一些组件是高度复用的状态标签正常/临期/过期、分段卡片、空状态占位、成员选择器。我把这些抽到了 widgets 目录。状态标签组件接收一个枚举值内部决定文字和颜色避免在药品卡片里重复写三遍颜色判断逻辑。空状态占位也统一药箱为空、今日无血糖记录、搜索结果为空三类场景用同一个模板加不同文案。这些组件后面再开发其他管理类应用时可以直接搬走相当于项目的一种隐性产出。6. 项目落地效果与影响边界6.1 项目解决了什么问题从家庭成员角度看药箱管理让“过期药”不再靠人脑记忆。首页的临期提醒和今日提醒把原本需要主动检查的事变成被动可见的信息。血糖记录则把纸质本子变成结构化数据连续记录几周之后趋势图能直观反映测量值的变化。从开发角度看这个项目完整覆盖了实体建模、本地数据库、页面状态管理、系统通知、自定义绘图、相机扫码这些常见技能点对学习 Flutter 的开发者来说是一套比较扎实的“健康类实战样本”。从影响范围上说整个项目不依赖云端服务数据完全留在本地隐私风险小部署成本低适合个人、家庭或小团队做内部工具。同时因为跨端适配的原因它天然保留了移植到其他系统的能力。6.2 明确的功能边界要清楚这不是一个临床级工具。我不做用药决策不判断药品相互作用也不生成任何诊断结论。App 里所有的参考区间都只是提示最终解释权在医院。如果涉及病人病历、处方数据这套本地存储的结构是不够安全的至少需要加密数据库和访问控制。数据模型也不适合直接对接医院平台缺少统一的术语编码体系只能作为个人健康数据的存储展示。这一点在项目开始时就明确记录下来避免后续被误用。6.3 如果重做一遍我会改什么第一会更早做真机冒烟测试而不是等 UI 全部写完才在开发板上跑前期很多适配问题完全可以减少。第二数据层一开始就用接口封装而不是直接在页面里调用数据库服务后面换存储引擎时页面逻辑不用重写。第三每张表从一开始就加上 updated_at 字段数据同步功能迟早需要它而非等要对接多设备时再手动迁移。第四提醒模块要更早把“系统通知 应用内列表”双通道做出来避免权限依赖导致重要提醒悄悄失效。整个项目做下来我最大的体会是健康工具类应用的价值不取决于功能列表有多长而在于数据是否准确、提醒是否可靠、老人是否愿意打开。把这三个问题想清楚很多技术选型就会自然收敛。最后再分享一个小技巧凡是记录类应用字段层级里“单位、日期、枚举”这三类信息一定要在建模阶段就定死宁可多花半天设计也不要后续返工成本完全不是一个量级。