做健康类工具,最烦的从来不是界面画得漂不漂亮,而是算法口径。同样一个 70 公斤、175 厘米的人,三款 App 能给你三个体脂率,差值能到 6 个百分点;同一个用户按"每天八杯水"喝了三天,发现体重没变、尿色还是黄的。这类问题不是设计问题,是数据和公式的问题。这篇就围绕一个我在 Flutter 跨平台鸿蒙开发里落地的练手项目——全能健康指数计算器,把 BMI、体脂率、每日热量、饮水量这四个指标从公式选型、参数计算、代码实现到鸿蒙端适配的完整路径拆开讲。它适合三类人看:刚上手 Flutter、想找个"有业务逻辑但不至于太重"的练手项目的朋友;已经在做鸿蒙端跨平台应用、卡在适配细节上的开发者;以及自己想做健康记录小工具、又不想被市面上那些张口就来的"标准值"忽悠的产品同学习。全文的公式、参数、代码结构都可以直接抄,但每一处我为什么这么选,也会讲明白。
1. 先把"算得对"放在"长得美"前面
1.1 为什么用 Flutter 承载一个"计算器型"应用
健康指数计算器这类应用有个很典型的特点:交互轻、逻辑重、状态少但精度敏感。它没有复杂的列表滚动、没有实时音视频、没有大量图片资源,但它对输入校验、单位换算、公式精度、历史记录一致性要求很高。这种形态最容易被误判成"随便什么框架都行",实际上恰恰是考验工程规范的场景。
选 Flutter 的第一层理由是一致性。四个指标的计算逻辑如果分别写在三个平台的原生代码里,早晚会出现三份实现漂移——安卓端把身高按整数截断了,iOS 端保留了一位小数,鸿蒙端又用了另一套活动系数表,用户换机之后数据对不上,这类问题排查成本极高。把计算层收敛到 Dart 侧,只做一次实现,多端复用,这是最直接的收益。
第二层理由是这个项目的 UI 形态。首页要塞下四组结论卡片、一个分段刻度的 BMI 标尺、一个环形进度、一个可切换的活动系数选择器。这类布局用声明式 UI 写起来非常省事,尤其是那个 BMI 标尺,本质上是CustomPaint画分段色带加一个指针,Android 原生要实现同等效果要写不少自定义 View 代码,而在 Flutter 里就是一个 50 行左右的 painter。
第三层理由是可测试性。计算器类应用最该做的其实就是单元测试,而 Dart 的测试生态足够轻,跑flutter test几秒钟出结果。把公式写成纯函数,输入输出全部可断言,这件事在跨平台项目里价值被严重低估——因为你只有一份逻辑,测试覆盖了就等于三端都覆盖了。
这里补充一句基于常见实践的判断:跨平台方案的选择永远是在"一致性"和"平台能力"之间取舍。健康计算器几乎不需要调用平台专属硬件(除了可选的步数、心率),所以一致性的权重远高于平台能力,Flutter 在这个坐标系里是很自然的选择。
1.2 四个模块的边界怎么切
很多人做这类项目,第一版就是把四个计算函数塞进一个utils.dart,页面里直接调。Demo 能跑,但第二版加"历史记录"、"目标设定"、"连续打卡"的时候就开始烂了。我的切法是三层:
- 数据层:只负责"用户是谁、身体参数是多少、今天喝了多少水",全是不可变对象加序列化。
- 计算层:纯函数集合,输入一个参数对象,输出一个结果对象,不碰 UI、不碰存储、不碰时间。
- 表现层:只消费结果对象,不做任何加减乘除。
这个切分最关键的约束是"计算层不许读系统时间"。饮水量这个模块天然依赖"今天是哪天"、"现在几点",如果把DateTime.now()写进计算函数里,单元测试就没法写了——你没法断言"晚上十点喝 300 毫升"这个场景。我的做法是把时间作为参数传进去,由表现层提供,计算层只做纯计算。
另外一个是模块之间的耦合问题。体脂率需要 BMI 吗?Deurenberg 公式里确实用到了 BMI,但那是"同一个 BMI 值"的复用,不是"体脂率依赖 BMI 模块"。所以我把 BMI 的计算抽成一个私有函数,体脂率自己调用它,而不是让体脂率去读 BMI 模块的输出对象。这样两个模块可以独立测试,未来换公式也不互相牵连。
1.3 状态管理与数据流的取舍
四个指标共享同一组输入(身高、体重、年龄、性别),这是典型的一对多依赖。用setState硬扛也能跑,但用户每改一次体重,四个结果卡片、一个标尺、一个进度环全要重建,稍微不注意就会看到输入框卡顿。
我实际用的是"单个不可变状态对象 + 局部重建"。状态类里装HealthInput和四个Result,页面用ValueListenableBuilder或者轻量状态容器订阅,把重建范围压在结果区,输入区本身不受影响。选这个方案而不是上重型状态管理库,理由很朴素:这个应用的状态结构太扁平了,一共就十几个字段,引入复杂的依赖注入和中间件属于给自己加戏。
提示:如果后续要加多用户、云端同步,这个扁平状态就会膨胀,那时候再迁移到带分片的状态方案也不迟。上来就按大型应用架构写,维护成本反而更高。
2. 四个核心指标的算法口径
2.1 BMI:公式三行,坑在标准和精度
BMI 的公式本身没争议:体重(kg)除以身高(米)的平方。一个 70 kg、1.75 m 的人,BMI = 70 / (1.75 × 1.75) = 70 / 3.0625 ≈ 22.86。问题全部集中在两处:单位换算和区间划分。
单位换算的坑在于输入。用户输入的可能是 175 cm,也可能手滑输成 1.75(把米当厘米填了),还可能输成 1750(多敲了一个 0)。我见过最离谱的处理方式是直接信任输入,结果算出 BMI = 22857,界面上标尺指针飞出屏幕。我的处理是在输入层做一次"物理合理性校验":身高限定在 80 到 250 厘米之间,体重限定在 20 到 300 公斤之间,超出范围直接拒绝提交并给出提示,而不是让它进到计算层。这不是偷懒,是因为这个范围的边界已经很宽松了,超出基本可以确定是单位错了。
区间划分的问题更微妙。国际通用的 BMI 分级是 18.5 / 25 / 30 三条线,但这套阈值是基于特定人群样本统计出来的,用来描述群体分布可以,用来判断个体就粗糙了。我在项目里的做法是提供两套阈值可切换:一套是通用的 18.5/25/30,另一套是针对亚洲人群调整过的 18.5/24/28。切换开关放在设置里,界面上标注清楚用的是哪一套,不做默认隐藏。
为什么不干脆只留一套?因为健康类工具最忌讳的是"我给你一个绝对结论"。BMI 本来就是个筛查指标,不是诊断指标,它不区分肌肉和脂肪,一个常年练力量的人 BMI 27、体脂 14%,你给他标红说他超重,这产品就废了。所以我甚至在结果卡片上加了一句固定的说明文案,提示 BMI 不适用于孕妇、运动员和未成年人群的评估。
| 指标 | 通用阈值 | 亚洲人群常用阈值 | 我的处理方式 |
|---|---|---|---|
| 偏瘦 | < 18.5 | < 18.5 | 蓝色标识,不建议继续减重 |
| 正常 | 18.5 ~ 24.9 | 18.5 ~ 23.9 | 绿色标识 |
| 超重 | 25.0 ~ 29.9 | 24.0 ~ 27.9 | 橙色标识 |
| 肥胖 | ≥ 30.0 | ≥ 28.0 | 红色标识,建议咨询专业人士 |
2.2 体脂率:两种公式的适用边界
体脂率是这个项目里最容易出问题的一块,因为它是估算,不是测量。市面上能用的口径大致三类:公式法(靠身高体重腰围算)、生物电阻抗法(体脂秤)、影像法(DEXA,最准但成本高)。作为一个纯软件工具,只能走公式法。
我实际实现了两套,让用户在设置里切换,原因是我自己踩过坑。
第一套是 Deurenberg 公式,只需要 BMI、年龄、性别:
- 男性:体脂率 = 1.20 × BMI + 0.23 × 年龄 − 10.8 × 1 − 5.4
- 女性:体脂率 = 1.20 × BMI + 0.23 × 年龄 − 10.8 × 0 − 5.4
用前面那个 70 kg、175 cm、30 岁男性算:BMI 22.86,体脂率 = 1.20 × 22.86 + 0.23 × 30 − 10.8 − 5.4 = 27.43 + 6.9 − 10.8 − 5.4 = 18.13%。这套公式的好处是输入少、计算快、结果稳定;坏处是它的误差范围比较大,文献里常见的说法是 ±4 到 ±5 个百分点,而且因为它完全基于 BMI 推导,所以继承了 BMI 的缺陷——肌肉量大的人会被高估。
第二套是腰围颈围法,需要额外测量腰围、颈围,女性还要加臀围:
- 男性:体脂率 = 86.010 × log10(腰围 − 颈围) − 70.041 × log10(身高) + 36.76
- 女性:体脂率 = 163.205 × log10(腰围 + 臀围 − 颈围) − 97.684 × log10(身高) − 78.387
这里有个必须注意的单位陷阱:这两条公式的原始定义里,腰围、颈围、身高的单位都是英寸。如果你直接用厘米代入,结果会错得离谱。所以代码里必须做一次除以 2.54 的换算,并且这个换算要写在公式函数内部,不能指望调用方记得。
举个具体例子。一位男性,身高 175 cm(68.90 英寸),腰围 85 cm(33.46 英寸),颈围 38 cm(14.96 英寸):
- 腰围 − 颈围 = 33.46 − 14.96 = 18.50,log10 = 1.267
- 身高 log10(68.90) = 1.838
- 体脂率 = 86.010 × 1.267 − 70.041 × 1.838 + 36.76 = 108.97 − 128.74 + 36.76 = 16.99%
对比 Deurenberg 给的 18.13%,差了 1.1 个百分点。这个差值在可接受范围内,而且腰围颈围法对肌肉量更敏感,更接近真实值。但它的问题是测量一致性——腰围量在肚脐水平还是髂骨上缘,颈围是喉结下方还是最细处,不同人量出来的差 3 到 5 厘米很常见,这个误差传导到体脂率上就是 2 个百分点以上。
所以我的产品策略是:默认用 Deurenberg,把腰围颈围法作为"进阶模式",并且强制用户在输入页看到一张测量位置示意图。如果用户两套数据都填了,结果页会同时展示两个数值并说明差异来源,而不是藏起来只给一个数。
注意:体脂率公式的适用人群有明确边界。Deurenberg 对 BMI 超过 35 的肥胖人群会系统性低估,对长期训练人群会高估。这不是实现问题,是公式本身的局限,产品文案里必须写清楚,别让用户拿这个数去做医疗判断。
2.3 热量:BMR 到底选哪套公式
每日热量这块,逻辑链条比 BMI 长得多:基础代谢率 BMR → 乘以活动系数得到每日总消耗 TDEE → 根据目标(减脂/维持/增肌)调整热量缺口或盈余 → 拆分三大宏量营养素。
BMR 的公式有好几套,我用的是Mifflin-St Jeor:
- 男性:BMR = 10 × 体重kg + 6.25 × 身高cm − 5 × 年龄 + 5
- 女性:BMR = 10 × 体重kg + 6.25 × 身高cm − 5 × 年龄 − 161
还是那个 70 kg、175 cm、30 岁男性:BMR = 700 + 1093.75 − 150 + 5 = 1648.75 kcal。
作为对照,老牌的 Harris-Benedict 修正式算出来会高一截:
- 男性:BMR = 88.362 + 13.397 × 70 + 4.799 × 175 − 5.677 × 30 = 88.362 + 937.79 + 839.825 − 170.31 = 1695.67 kcal
差了将近 47 kcal。选 Mifflin-St Jeor 的理由是,这套公式在近年的人群验证研究里对现代体型的偏差更小,Harris-Benedict 因为样本年代较早,普遍存在高估。这个差距一天看不出来,但按 10% 的热量缺口做减脂,47 kcal 的初始偏差会被放大。
活动系数我用的是五档标准值,并且在界面上把每一档对应的生活场景写出来,而不是只给一个数字让用户猜:
| 档位 | 系数 | 场景描述 | 计算示例(基于 BMR 1648.75) |
|---|---|---|---|
| 久坐 | 1.200 | 办公室工作,几乎不额外运动 | 1978.5 kcal |
| 轻度活动 | 1.375 | 每周 1-3 次轻量运动 | 2267.0 kcal |
| 中度活动 | 1.550 | 每周 3-5 次中等强度运动 | 2555.6 kcal |
| 高度活动 | 1.725 | 每周 6-7 次高强度运动 | 2844.1 kcal |
| 极高活动 | 1.900 | 体力劳动或一日两练 | 3132.6 kcal |
目标调整这块我做了保守化处理。减脂档位给的缺口是按 TDEE 的 15% 算,并且设了一个下限保护:任何情况下计算出的目标摄入不低于 BMR 的 1.1 倍,也不低于 1200 kcal(女性)/ 1500 kcal(男性)。为什么加这个下限?因为纯粹的百分比缺口对体重基数大的人会产生很夸张的数字,一个 TDEE 3500 的人按 25% 缺口算出来 2625,看着还行;但如果用户把活动系数选成极高、体重又填得高,算出来一天吃 1800 也是有可能的,长期这样吃会掉肌肉。软件不该鼓励极端操作,宁可让它"算得保守一点"。
蛋白质的推荐量我按目标分了三档:维持期 1.4 g/kg,减脂期 1.8 g/kg,增肌期 2.0 g/kg。这个数值比通用推荐量高一些,原因是减脂期需要更高的蛋白摄入来保住瘦体重。脂肪按总热量的 25% 折算,剩下的热量全给碳水,这个分配逻辑在代码里就是一个三行的函数,但它比"碳水 50%、蛋白 30%、脂肪 20%"这种固定比例要合理得多,因为固定比例在小体重人群身上会算出蛋白质不足。
2.4 饮水:把固定值改成动态区间
"每天八杯水"是这个领域流传最广的错误建议之一。杯子的容量没有定义,人的体重、活动量、环境温度、饮食结构全都不一样,怎么可能是一个固定值。
我采用的口径是权重法打底:基础饮水量 = 体重kg × 33 毫升。70 kg 的人对应 2310 毫升,比"八杯水"(按 250 ml 一杯算是 2000 ml)略高,符合一般成人推荐区间。
在此之上做三项动态修正:
- 运动补充:按运动时长每小时追加 500 毫升。这个值的依据是运动过程中的汗液流失和呼吸失水,一般中等强度运动每小时净流失在 400-800 ml 之间,取中位数。
- 环境温度修正:气温高于 28 摄氏度时,每高 1 度追加基础量的 3%,上限追加 30%。举例:基础 2310 ml,气温 33 度,追加 5 × 3% = 15%,即 346.5 ml,合计 2656.5 ml。
- 饮食修正:这是很多人忽略的一项。高蛋白饮食、高钠饮食、饮酒、咖啡因摄入都会影响水需求。我在设置里做了一组开关,用户勾选"今天高蛋白/高盐/喝了酒",每种情况追加 200-300 毫升。
为什么用区间而不是单值?因为饮水量的合理范围本来就宽。我最终展示的是一个区间,比如"2310 ~ 2770 毫升",把基础值和修正值分别标出来,用户看到的是"为什么是这个数",而不是一个孤零零的数字。这在产品体验上的差别很大——单值会让人产生"必须喝到位"的强迫感,区间则允许弹性。
实操心得:饮水模块最好和记录强绑定,让用户点一次"喝了 250 ml"就累加,而不是让他自己算今天还差多少。我用的是可撤销的累加模式,误点一次可以长按删除,比弹窗确认的体验好得多。
3. 数据模型与计算层的代码落地
3.1 不可变数据类与单位归一
数据模型这块我踩过的一个坑是单位不统一。第一版里height字段有的地方当厘米用,有的地方当米用,注释也没写清楚,结果体脂率算出来差了两个数量级。后来我把原则定死:内部一律用厘米和公斤,只在计算函数内部临时换算。
enum Gender { male, female } enum ActivityLevel { sedentary, light, moderate, active, veryActive } class HealthInput { final double heightCm; final double weightKg; final int age; final Gender gender; final double? waistCm; final double? neckCm; final double? hipCm; final ActivityLevel activity; const HealthInput({ required this.heightCm, required this.weightKg, required this.age, required this.gender, this.waistCm, this.neckCm, this.hipCm, this.activity = ActivityLevel.sedentary, }); bool get hasWaistData => waistCm != null && neckCm != null && (gender == Gender.male || hipCm != null); HealthInput copyWith({ double? heightCm, double? weightKg, int? age, Gender? gender, double? waistCm, double? neckCm, double? hipCm, ActivityLevel? activity, }) { return HealthInput( heightCm: heightCm ?? this.heightCm, weightKg: weightKg ?? this.weightKg, age: age ?? this.age, gender: gender ?? this.gender, waistCm: waistCm ?? this.waistCm, neckCm: neckCm ?? this.neckCm, hipCm: hipCm ?? this.hipCm, activity: activity ?? this.activity, ); } }用不可变对象的原因不在于时髦,而在于避免计算层偷偷改输入。一个可变对象传进函数,函数内部改了某个字段又没改回来,这种 bug 在跨平台项目里极难定位,因为你看不到调用栈上的现场。全部final之后,编译器就帮你把这个可能性封死了。
hasWaistData这个 getter 也很关键。它把"数据齐不齐"的判断集中在一处,UI 层和计算层用同一套条件,不会出现 UI 上按钮亮着、点下去计算层抛异常的情况。
3.2 计算服务的纯函数化
计算层的写法我坚持一个原则:输入参数对象,返回结果对象,中间不读任何外部状态。
class BmiResult { final double value; final String category; final int colorLevel; // 0 偏瘦 1 正常 2 超重 3 肥胖 const BmiResult(this.value, this.category, this.colorLevel); } BmiResult calcBmi(double weightKg, double heightCm, {bool asiaStandard = false}) { final h = heightCm / 100; final v = weightKg / (h * h); final a = double.parse(v.toStringAsFixed(1)); late final String cat; late final int lvl; final t1 = 18.5; final t2 = asiaStandard ? 24.0 : 25.0; final t3 = asiaStandard ? 28.0 : 30.0; if (a < t1) { cat = '偏瘦'; lvl = 0; } else if (a < t2) { cat = '正常'; lvl = 1; } else if (a < t3) { cat = '超重'; lvl = 2; } else { cat = '肥胖'; lvl = 3; } return BmiResult(a, cat, lvl); }注意double.parse(v.toStringAsFixed(1))这一行。先四舍五入到一位小数,再拿去比较阈值,是为了避免出现"显示 24.9 但被判成超重"这种前后矛盾。如果不做这一步,24.94 显示出来是 24.9,但比较时是按 24.94 和 25 比,判定为正常;而 24.96 显示 25.0,判定超重,用户就会看到两个都显示 25.0 的数值被分到了不同区间。
体脂率的实现要处理单位换算,我把换算写在函数里:
double calcBodyFatDeurenberg({ required double bmi, required int age, required Gender gender, }) { final g = gender == Gender.male ? 1 : 0; return 1.20 * bmi + 0.23 * age - 10.8 * g - 5.4; } double calcBodyFatNavy({ required double heightCm, required double waistCm, required double neckCm, double? hipCm, required Gender gender, }) { const cmToIn = 2.54; final h = heightCm / cmToIn; final w = waistCm / cmToIn; final n = neckCm / cmToIn; if (gender == Gender.male) { final diff = w - n; if (diff <= 0) return double.nan; return 86.010 * (log(diff) / ln10) - 70.041 * (log(h) / ln10) + 36.76; } else { final hip = (hipCm ?? 0) / cmToIn; final diff = w + hip - n; if (diff <= 0) return double.nan; return 163.205 * (log(diff) / ln10) - 97.684 * (log(h) / ln10) - 78.387; } }log(x) / ln10就是 log10,Dart 的dart:math只提供自然对数,所以要手动换算。这一点很容易在移植代码时被忽略——把别的语言里现成的Math.log10直接改成log,结果全错。差值小于等于 0 时返回 NaN 而不是抛异常,是为了让 UI 层能优雅降级,显示"数据不合理,请重新测量"。
3.3 边界值与异常输入的兜底
边界处理这块我列了一个清单,每次改公式都要过一遍:
- 除零:身高为 0 时 BMI 计算会得到 Infinity。输入校验已经拦住了,但计算层仍然要判
heightCm <= 0直接返回 NaN。 - 负值:腰围减颈围为负,取对数会得到 NaN。前面代码里的
diff <= 0判断就是拦这个。 - 超范围:年龄填 200 岁,Deurenberg 公式会算出离谱的体脂率。我把年龄限制在 10 到 100 之间。
- 精度溢出:腰围颈围法里如果颈围填 8 cm(少写一位),log10 结果是负数很大,会算出一个夸张的体脂率。这类输入没法靠范围校验完全拦住,我在结果页加了一个"合理性提示":体脂率低于 3% 或高于 55% 时,界面展示黄色警示条,提示结果可能异常。
注意:不要让计算函数抛异常。UI 层每渲染一帧都可能调用它,抛异常会导致整页白屏。统一返回 NaN 或空结果对象,由展示层决定怎么提示,这个约定要在项目早期就定好。
4. 鸿蒙端工程搭建与适配实录
4.1 目录结构与平台通道
Flutter 跨平台到鸿蒙,工程结构上大致是多出一个平台目录,和安卓、iOS 并列。我的目录组织是把业务代码和平台相关代码彻底隔开:
lib/ core/ models/health_input.dart models/results.dart calc/bmi.dart calc/body_fat.dart calc/calorie.dart calc/water.dart storage/local_store.dart features/ home/ history/ settings/ app.dart main.dart这个结构的核心思想是:core目录下的东西不 import 任何 UI 组件,纯 Dart。这样在写单元测试的时候可以直接跑,不需要起一个 widget 环境。实测下来,core目录的测试跑完不到 4 秒,覆盖率能做到 90% 以上。
平台通道这块,健康计算器其实几乎不需要。唯一可能用到的是读取系统步数来修正活动系数。我的处理是先不做,把活动系数交给用户手选,原因有两个:一是步数权限在各平台的授权流程差异很大,做起来成本高;二是步数本身对活动系数的估算是粗粒度的,用户手动选档反而更准。这是我做减法的一个决定,值得记录一下,因为很多人做项目喜欢把能做的功能都做上,最后每个功能都半成品。
签名配置是在鸿蒙配套 IDE 里完成的,流程大致是打开工程目录、配置自动签名、选择调试设备。这一步需要注意:签名配置是跟设备绑定的,换一台真机调试就要重新配置一次。我第一次换设备时页面一直装不上,排查了半天才发现是签名里的设备标识没更新。
4.2 渲染与手势差异的三处调整
跨平台渲染的差异在这类应用里集中在三个地方,都是我实际调出来的。
第一处是圆角与阴影的观感。同一套BoxShadow参数在不同平台上的阴影扩散范围看着不一样,鸿蒙端的阴影偏硬一点。我把阴影从"一个大模糊半径"改成了"两层叠加":一层 2px 模糊做边缘过渡,一层 8px 模糊做整体投影,两端的观感就接近了。这个调整纯靠肉眼对比真机截图,没有理论值,建议的做法是先定一套基准(比如按视觉稿),在两个平台上分别截图,然后调参数对齐。
第二处是文字的行高。同样的fontSize,不同平台默认字体不一样,行高会差 1-2 像素。这个差异在段落文字里不明显,但在那个 BMI 标尺的刻度标签上就会导致文字基线错位。我的解决方式是在TextStyle里显式指定height属性,不依赖默认行高,同时把刻度标签全部改用CustomPaint里的TextPainter手动定位,告警就消失了。
第三处是手势冲突。首页有一个横向滚动的指标卡片区,卡片内部又有可点击的按钮。在其中一个平台上,横向滑动时经常误触按钮。这是手势竞技场的判定差异,我的处理是给外层滚动加一个dragStartBehavior设置,同时把内部按钮的可点击区域向内收 4 像素,给滑动手势留出响应空间。
NotificationListener<ScrollNotification>( onNotification: (n) { if (n is ScrollStartNotification) { FocusScope.of(context).unfocus(); } return false; }, child: ListView( dragStartBehavior: DragStartBehavior.down, scrollDirection: Axis.horizontal, children: cards, ), )这段代码里的小动作是滑动开始时收起键盘。健康计算器有大量数字输入,用户输完体重直接滑动看结果,键盘不收起来会挡住下半屏的结果卡片,这个细节不写就会被自己的测试用例打脸。
4.3 本地持久化方案
历史记录用本地存储就够了,不需要联网。我选的是键值型存储加 JSON 序列化的方案,理由是这个数据量太小——一天最多一条记录,一年 365 条,JSON 字符串也就几十 KB,上个数据库属于过度设计。
存储结构按日期做 key,值为当日完整的输入参数和结果快照:
class DailyRecord { final String date; // yyyy-MM-dd final double weightKg; final double bmi; final double bodyFat; final int calorieTarget; final int waterTargetMl; final int waterActualMl; Map<String, dynamic> toJson() => { 'date': date, 'weightKg': weightKg, 'bmi': bmi, 'bodyFat': bodyFat, 'calorieTarget': calorieTarget, 'waterTargetMl': waterTargetMl, 'waterActualMl': waterActualMl, }; static DailyRecord fromJson(Map<String, dynamic> j) => DailyRecord( date: j['date'] as String, weightKg: (j['weightKg'] as num).toDouble(), bmi: (j['bmi'] as num).toDouble(), bodyFat: (j['bodyFat'] as num).toDouble(), calorieTarget: (j['calorieTarget'] as num).toInt(), waterTargetMl: (j['waterTargetMl'] as num).toInt(), waterActualMl: (j['waterActualMl'] as num).toInt(), ); }这里用的是as num而不是as double,是因为 JSON 反序列化时整数会被解析成int,如果原始数据里存的是70而不是70.0,as double会抛类型转换异常。这个坑我在测试环境里被绊过一次,本地存储里旧的记录全部解析失败,页面白屏。加一层num中间转换就解决了。
关于"存结果快照"还是"存输入重算",我选的是前者。原因是公式会更新,如果只存输入,用户一年后打开历史记录,看到的所有历史数值都会变成新公式算出来的结果,历史曲线就失真了。存快照的代价是数据冗余,但几十 KB 的冗余换来的是数据可追溯,这个交换很值。
4.4 真机验证流程
我的验证清单是这样跑的,顺序不能乱:
- 单元测试全跑一遍,覆盖四个计算模块的边界值(最低身高、最高体重、腰围等于颈围等)。
- 模拟器上跑一遍主流程,确认无渲染异常。
- 真机上装包,重点看三件事:输入框弹出数字键盘时页面是否上推、长列表滚动是否掉帧、深色模式下色带对比度是否够。
- 杀进程重启,确认本地数据还在。
- 跨天测试:手动把设备时间改到第二天,检查饮水记录是否正确归零、历史记录是否正确生成新条目。
第 5 步是最容易漏的。我第一版就没做这个,结果用户跨零点还继续喝水,数据全累加到前一天,第二天的进度环一片空白。修法是把"当前记录属于哪天"的判断抽成一个函数,每次写入前先比对日期,不一致就落盘旧记录、开新记录。
5. 界面:让一屏装下四组结论
5.1 输入区的容错
健康类应用最影响体验的不是结果准不准,而是输入烦不烦。四个指标需要身高、体重、年龄、性别、腰围、颈围、臀围、活动系数、运动时长、气温,加起来十几个字段,全塞一屏就是灾难。
我的做法是分层展开。首屏只要四个必填项:身高、体重、年龄、性别。填完立刻出 BMI 和基础热量两个结果。体脂率的进阶模式、饮水量的修正项、活动系数的细调,全部收在"更多参数"里,默认折叠。这个设计的依据是用户心理——先给点反馈,他才有动力继续填。实测下来,首屏四项填完的平均耗时不到 20 秒。
数字输入框的处理有几个细节值得记下来:
- 用
TextInputType.numberWithOptions(decimal: true),允许小数点但不弹全键盘。 - 输入框的
onChanged里做实时格式化,把连续的小数点、开头多余的 0 清掉。 - 失焦时才做范围校验并提示,不要在用户还没输完时就飘红。
- 单位固定在输入框右侧作为
suffixText,不放在 placeholder 里,避免用户输完就看不见单位了。
5.2 结果区的可视化
BMI 的标尺我用CustomPaint画:一条水平色带分四段,颜色从蓝到绿到橙到红,指针是一个小三角加一条竖线。
class BmiGaugePainter extends CustomPainter { final double bmi; final bool asia; BmiGaugePainter(this.bmi, this.asia); @override void paint(Canvas canvas, Size size) { const minBmi = 14.0; final maxBmi = asia ? 34.0 : 36.0; final stops = [18.5, asia ? 24.0 : 25.0, asia ? 28.0 : 30.0]; final colors = [ const Color(0xFF4A90D9), const Color(0xFF3FAE6A), const Color(0xFFE39A2B), const Color(0xFFD4534C), ]; final bounds = [minBmi, ...stops, maxBmi]; final paint = Paint()..style = PaintingStyle.fill; for (var i = 0; i < bounds.length - 1; i++) { final left = (bounds[i] - minBmi) / (maxBmi - minBmi) * size.width; final right = (bounds[i + 1] - minBmi) / (maxBmi - minBmi) * size.width; paint.color = colors[i]; canvas.drawRRect( RRect.fromRectAndRadius( Rect.fromLTRB(left + (i == 0 ? 0 : 1), 0, right - (i == bounds.length - 2 ? 0 : 1), size.height), const Radius.circular(3), ), paint, ); } final clamped = bmi.clamp(minBmi, maxBmi).toDouble(); final x = (clamped - minBmi) / (maxBmi - minBmi) * size.width; final pointer = Paint()..color = const Color(0xFF2B2B2B); canvas.drawRect(Rect.fromLTRB(x - 1, 0, x + 1, size.height), pointer); } @override bool shouldRepaint(covariant BmiGaugePainter old) => old.bmi != bmi || old.asia != asia; }shouldRepaint里的比较是这个 painter 的性能关键。如果直接返回true,每次输入变化都会重绘,肉眼看不出来但底层有额外开销;比较两个字段是最省事的正确做法。另外clamp那一步不能省,用户输入极端值时指针会画出画布外。
环形进度我用来展示饮水量,但它不是简单的百分比环,而是三段式:已完成部分实心、目标区间部分半透明、超出部分换一种颜色。这个设计解决了一个体验问题——用户喝超了之后,简单的百分比环只能停在 100%,看起来像卡住了,三段式可以表达"你已经超出目标 300 ml"。
5.3 首页信息密度
四组结论放一屏,最容易犯的错是四张卡片同样大小。实际上用户对四个指标的关注度差异很大,体重和 BMI 是高频看,体脂率和饮水量是低频看。所以我的布局是:BMI 大卡占上方通栏,带标尺;热量和饮水两张中卡并排;体脂率作为小卡放在最下,点开才展开详情。
这个布局还有个隐藏收益:BMI 大卡上可以放一句动态文案,比如"比上周下降了 0.4",把历史数据用起来。这类文案如果四个卡片都放,信息过载;只放在主卡上,反而成了用户每次打开的动力。
6. 常见问题与排查实录
6.1 数值类问题
体脂率算出来是负数。第一次遇到时我以为是公式写错了,排查发现是 Deurenberg 公式在年龄很小、BMI 很低的情况下确实会返回负值。比如 BMI 16、年龄 12 的男性:1.20 × 16 + 0.23 × 12 − 10.8 − 5.4 = 19.2 + 2.76 − 16.2 = 5.76,还正常;但如果 BMI 15、年龄 10,就是 18 + 2.3 − 16.2 = 4.1。要到负数需要 BMI 特别低。虽然罕见,但代码里还是要 clamp 到 2% 的下限,并且在结果区标注"该结果可能不适用您的情况"。
热量目标算出来比 BMR 还低。这是缺口比例的锅,前面提到过,减脂档位必须加下限保护。我的实现里加了两道:不低于 BMR × 1.1,不低于性别对应绝对下限。
饮水量和别的应用差很多。这个不一定是 bug,可能只是口径不同。国内外常见的推荐口径有 30 ml/kg、35 ml/kg、40 ml/kg 三种,加上是否计入食物中的水分(一般食物贡献约 20%),差异会到 500 ml 以上。我的处理是在设置页放一段说明,写清楚本应用采用 33 ml/kg 且不含食物水分,用户自己判断。
6.2 跨端表现类问题
深色模式下色带看不清。四段色的中间两段(绿、橙)在深色背景上对比度不足,指针的深灰色几乎消失。解决方式是用两套色值,深色模式下把指针换成浅灰,同时把色带的饱和度降一档。这个问题在浅色模式的模拟器上完全看不出来,必须切深色模式真机验证。
长按复制在输入框里失效。数字键盘弹起时,长按选中的手势被外层的手势识别器拦截了。修法是给输入框外面包一层GestureDetector并设置behavior: HitTestBehavior.translucent,让手势能穿透。
页面返回后输入丢失。导航到设置页再返回,首屏的输入全清空了。原因是状态对象建在了页面内部,页面重建就没了。把状态提升到路由之上,或者用PageStorageKey保活,两种方案都行,我选前者,因为计算器需要多个页面共享同一份输入。
6.3 状态与性能类问题
每输入一个字符就重算四组结果,输入框卡顿。原因是onChanged里直接触发了全量状态更新。修法是加防抖,250 毫秒内的连续输入只计算最后一次。
Timer? _debounce; void onWeightChanged(String v) { _debounce?.cancel(); _debounce = Timer(const Duration(milliseconds: 250), () { final w = double.tryParse(v); if (w != null) setState(() => _weight = w); }); }防抖的副作用是"输完立刻点保存"可能存到旧值。所以保存按钮的点击处理里要先_debounce?.cancel()再强制读一次当前输入框的值,这个细节不做就会出现偶发的数据不一致,而且极难复现。
历史记录页滚动掉帧。一年 365 条记录,用ListView硬渲染全部卡片,滚动明显卡。换成ListView.builder之后解决,这是最基础的问题,但我确实在第一版犯过。
6.4 常见问题速查表
| 现象 | 可能原因 | 排查动作 | 处理方式 |
|---|---|---|---|
| BMI 显示异常大 | 身高单位填成了米 | 打印原始输入值 | 输入层加范围校验 80-250 cm |
| 体脂率 NaN | 腰围减颈围小于等于 0 | 检查测量值 | 返回 NaN,UI 提示重新测量 |
| 体脂率偏高 5% 以上 | 公式用了厘米未换算英寸 | 断点看中间变量 | 在函数内部统一除以 2.54 |
| 热量目标低于 BMR | 缺口比例过大 | 检查活动系数档位 | 加双重下限保护 |
| 饮水量跨天不归零 | 未比对日期 | 改设备时间测试 | 写入前比对日期,不一致则新建记录 |
| 历史记录解析报错 | JSON 整数被当 int | 看异常堆栈类型 | 用as num再.toDouble() |
| 输入框卡顿 | 无防抖,每字符全量重算 | Flutter DevTools 看重建次数 | 加 250 ms 防抖 |
| 深色模式指针消失 | 指针色值为写死的深灰 | 切深色模式真机看 | 双套色值 |
7. 几个踩过之后才明白的经验
第一个经验是关于公式的。我一开始觉得"公式越准越好",所以默认上了腰围颈围法。结果用户的反馈是"我不知道腰围该量哪儿"。后来改成默认 Deurenberg、进阶才用腰围法,反而留存更好。在消费级工具里,可执行的粗略结果,价值高于难执行的精确结果。这句话我后来用在了好几个项目上。
第二个经验是关于参数展示的。最初结果页只给数字,比如"体脂率 18.1%"。后来我把计算过程也展示出来——用的哪套公式、代入的原始值、阈值是哪一套。用户看到"哦,它用的是 Deurenberg,难怪和我的体脂秤差 2 个点",就不会再怀疑软件坏了。让用户看见计算过程,比让数字更准更能建立信任。
第三个是关于测试的。我给四个模块写了测试,但漏了一类用例:跨月、跨年、闰年、夏令时。饮水量按日期归零这个逻辑,在 2 月 28 日到 3 月 1 日、12 月 31 日到 1 月 1 日这几个边界上如果用的是字符串比较而不是日期对象比较,就会出现归零不生效或者记录覆盖。后来我改成统一用yyyy-MM-dd格式化后的字符串做 key,并且在测试里显式覆盖了月初、月末、年末三个边界。
最后一个,也是我个人觉得最值得分享的:这个项目里我删掉的功能,比加上的功能多。步数同步删了、社交分享删了、云端备份删了、连续打卡的成就体系删了。删的理由都一样——它们不服务于"把四个指标算对、看清楚"这一个核心目标。跨平台开发的诱惑在于"反正写一次能跑三端,多做点不亏",但真实情况是每多一个功能,就多一份三端的适配成本和多一份测试成本。这个健康计算器最后留下来的就是一屏输入、一屏结果、一页历史,加上一个设置页,代码量不大,但我自己用着顺手,给别人看的时候也能一句话说清楚它在干什么。