如果你最近在折腾 Flutter for OpenHarmony,一定体会过那种“代码在 Android 上还好好的,换到板子上就各种不对劲”的挫败感。我最近做的健康管理App里,第一版资料录入就是身高、性别、生日三个字段,原以为半天能收工,结果从选择器手感、日期解析到页面数据回传,前前后后调了一周。这个页面看起来简单,但它同时覆盖了列表滑动、自绘控件、文本输入校验、枚举建模和跨端适配,几乎把一个健康类应用最常见的底层问题都浓缩进来了。
所以我把这个“身高性别生日输入”页面单独拿出来复盘:为什么身高尺要用 CustomPaint 自绘而不是现成滚轮,为什么生日组件最大的坑是 DateTime.parse 的静默归一化,为什么性别字段一定不能直接把按钮上的“男”字传给服务端,以及最后在 RK3568 这类开发板上实际跑 Flutter 页面时,哪些问题值得你先有个心理准备。
这篇文章不是完整工程源码,而是把实现思路、关键代码片段和踩坑过程展开讲,适合已经在用 Flutter 做业务、正准备往 OpenHarmony 上迁移的人,也适合第一次跑 Flutter for OpenHarmony Demo、却不知道页面层该从哪下手的新手。
1. 为什么先拿“身高性别生日”开刀而不是做一个完整首页
1.1 健康资料录入页其实是一个浓缩的跨端考题
很多从 Android/iOS 转过来做 Flutter 的人,会低估 OpenHarmony 适配带来的连锁反应。你写的还是 Dart,用的大部分 Widget 也没什么区别,但真正跑到 OpenHarmony 标准系统设备上,页面里的日期选择器弹层、软键盘避让、滚轮手势、底层文本输入通道都会因为系统能力不同出现差异。
我选择“身高、性别、生日”这个三件套,是因为它不像整个首页那样需要一堆业务模块联动,却又足够覆盖几种最典型的交互形态——身高需要滑动选择器,生日需要处理日期输入与合法性判断,性别需要做选项区块。这三个字段凑在一起,就是一个微型的表单闭环:数据采集、校验、组装回传。
更重要的是,健康管理类 App 里这三项几乎是所有后续功能的地基。BMI 计算需要身高体重,基础代谢估算需要性别年龄,推荐方案往往也跟生日强相关。一个输入组件做得稳不稳,直接影响后面十几个页面。
1.2 Flutter 跑上 OpenHarmony:现在到底到什么程度了?
我用的不是 Android 那套分支,而是开源社区里针对 OpenHarmony 维护的 Flutter 支持版本。它把 Flutter Engine 的渲染、事件分发、平台通道都接到了 OpenHarmony 的 Ability 框架上,Dart 层和标准 Flutter 基本保持一致,所以你原有的业务代码迁移成本远比你想象的低。
我项目里创建工程时,大致是这条命令:
bash复制flutter create --platforms=ohos health_app
如果你的 Flutter 分支支持这个参数,生成之后会多出一个 ohos 目录。后续在 DevEco Studio 里打开这个目录,就可以构建出能安装到开发板或模拟器上的 .hap 包。不过每个版本适配程度不完全一样,如果你拉到的版本还没支持 --platforms=ohos,那就直接从一个现成的 OpenHarmony Flutter 模板复制 ohos 目录进来,再改包名,效果是一样的。
有一点要提前说清楚:Flutter 在 OpenHarmony 上的“适配完整”,指的是渲染层和输入层大体可用,并不代表所有原生 Plugin 都有 OpenHarmony 实现。像一些第三方的定位、推送 SDK,通常只适配了 Android/iOS,在这边会直接缺省。可我这里做的资料录入页面没有依赖任何原生能力,所以正好是一个纯 Flutter 能力就能跑通的场景,非常适合作为第一个真正意义上的 OpenHarmony 迁移页面。
1.3 这篇经验适合谁
如果你已经在 Flutter 里写过完整业务页面,那读这篇文章时重点看我踩坑的几处判断;如果你还没真正在 OpenHarmony 设备上跑过,那建议把环境先搭好,找一个能启动镜像的 RK3568/RK3588 开发板或模拟器,跟着代码思路自己敲一遍。
我不建议一上来就照着某篇“万能模板”把整个首页从 Android 往 OpenHarmony 搬。先拿一个三个字段的资料页跑通,你会把页面路由、软键盘、状态回传、数据持久化这些最基础的链路全部摸一遍,再复制到复杂页面时,心里就有底了。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 身高滑尺:为什么我放弃了现成滚轮,改用 CustomPaint 自绘
2.1 从 Android 滚轮到 OpenHarmony 滚轮:手感的意外差异
最早写身高选择器,我脑子里第一反应就是用 ListWheelScrollView 或者第三方日期滚轮库。在 Android 模拟器上跑得好好的,惯性、回弹都正常。可换到 OpenHarmony 的开发板上,整个滚轮的手感突然变得不太一样。表现是:抬手之后的惯性滑动时快时慢,阻尼物理参数明显和 Android 底层不一致,列表项偶尔会停在两个数值中间,需要再补一次轻扫才能吸附。
我一开始以为是触摸事件采样问题,后来用日志打点发现,问题源头在 Flutter Engine 对 OpenHarmony 触摸输入的响应策略上。不同版本、不同开发板的固件对触摸事件的处理会有差别,而 ListWheelScrollView 这种滚动型组件对手势物理参数非常敏感。如果在页面里强行调 ScrollConfiguration 的 physics,在 Android 上可能又变难用,两边很难同时满意。
这不是说 Flutter 在 OpenHarmony 上不能用滚轮,而是说当你需要“始终展示同一把尺子、用户拖动到哪里都要给出整数身高”的时候,原生滚轮的过拟合成本已经超过收益了。于是我干脆放弃列表滚动模型,改用 CustomPaint 自绘一把竖直的刻度滑尺,把拖动的每一步都掌握在自己手里。
2.2 一把“尺子”的绘制与坐标换算
自绘的思路很简单:画一个竖直方向的尺子,尺子中心有一条固定指示线。身高的值越大,指示线对应的刻度位置越靠上。手指上下拖动时,变化的是尺子本身的平移量,而不是容器内部某个列表项的选中态。
我先定义每 1cm 占据多少像素高度。比如 pixelPerCm = 14.0,演示范围取 120cm 到 220cm,这样整把尺子大概是 1400 像素的虚拟高度,屏幕只显示中间一段,通过平移访问不同区间。
核心画笔大致是这样:
dart复制class HeightRulerPainter extends CustomPainter {
HeightRulerPainter({
required this.currentCm,
required this.minCm,
required this.maxCm,
required this.pixelPerCm,
});
final double currentCm;
final int minCm;
final int maxCm;
final double pixelPerCm;
@override
void paint(Canvas canvas, Size size) {
final centerY = size.height / 2;
// 当前值对应的刻度应落在画布中央
final currentY = (maxCm - currentCm) * pixelPerCm;
final shiftY = centerY - currentY;
// 画背景与刻度
final tickPaint = Paint()
..color = const Color(0xFFB0B7C3)
..strokeWidth = 2;
for (var cm = minCm; cm <= maxCm; cm++) {
final y = shiftY + (maxCm - cm) * pixelPerCm;
final isMajor = cm % 10 == 0;
final tickWidth = isMajor ? 22.0 : 12.0;
canvas.drawLine(
Offset(size.width / 2 - tickWidth / 2, y),
Offset(size.width / 2 + tickWidth / 2, y),
tickPaint,
);
// 整十刻度附近画数字
if (isMajor) {
// 用 TextPainter 画出 cm.toString()
}
}
// 画中央指示线
final markerPaint = Paint()
..color = const Color(0xFFFF6B35)
..strokeWidth = 3;
canvas.drawLine(
Offset(size.width / 2 - 30, centerY),
Offset(size.width / 2 + 30, centerY),
markerPaint,
);
}
@override
bool shouldRepaint(covariant HeightRulerPainter oldDelegate) {
return oldDelegate.currentCm != currentCm;
}
}
这段代码有一个很关键的设计:我并没有把“当前选中的身高值”当成一个独立游标,而是完全通过 shiftY 把整个尺子平移到屏幕中心。这样当你拖动尺子时,中央那条线始终不动,动的是背后的刻度。这种隐喻和真实拉尺子的操作一致,用户看一眼就知道中间那条橙色的线就是当前选中值。
2.3 拖动手势与边界阻尼的完整处理
有了画布,接下来是手势。我选择 GestureDetector 的纵向拖动回调,核心是把 delta.dy 转换成身高变化值。
dart复制double _tmpCm = 170; // 当前显示的 cm
double? _dragStartCm;
void _onVerticalDragStart(DragStartDetails details) {
_dragStartCm = _tmpCm;
}
void _onVerticalDragUpdate(DragUpdateDetails details) {
final start = _dragStartCm;
if (start == null) return;
// 手指向下拉(delta.dy > 0),指示线看到的是更矮的身高
double next = start - details.delta.dy / pixelPerCm;
// 越界后采用阻尼,避免一下被甩出去
if (next < minCm) {
next = minCm - (minCm - next) * 0.25;
} else if (next > maxCm) {
next = maxCm + (next - maxCm) * 0.25;
}
setState(() => _tmpCm = next);
}
void _onVerticalDragEnd(DragEndDetails details) {
_dragStartCm = null;
// 松手吸附到整数身高
final snapped = _tmpCm.round().clamp(minCm, maxCm).toDouble();
setState(() => _tmpCm = snapped);
widget.onHeightChanged(snapped.toInt());
}
这里要注意方向符号。很多第一次写的人会顺手把 delta.dy 加到数值上,结果发现手指往下拖身高越来越大,体验完全反过来。我的约定是:身高越大,刻度在画布上的位置越靠上,也就是更大的 cm 对应更小的 y 坐标;手指向下移动时,中心线正在阅读的是相对更矮的身高,所以要用减法。
边界阻尼也值得单独说。如果不做阻尼,用户在边界处会感到“被墙硬挡住”;如果做了阻尼,又能在视觉上给用户一个“到底了”的反馈。我的阻尼系数设成 0.25,也就是说手指拖出边界 4cm,刻度最多偏移 1cm,松手后立刻通过吸附逻辑弹回边界。这个手感在 RK3568 板子上同样稳定,因为它根本不依赖系统滚动物理,完全是由自己的数学公式决定的。
2.4 绘制性能:TextPainter 别在 paint 里裸用
CustomPaint 自绘最容易踩的性能坑,是每一帧都在 paint 里创建新的 TextPainter 并调用 layout。在 Android 高性能设备上可能还好,但在 RK3568 这类中低算力 SoC 上,拖一次尺子会明显感到掉帧。
我的处理方式是把刻度上的文字预渲染成位图缓存。120cm 到 220cm,整十刻度一共 11 个数字,量很少,完全可以在初始化时生成 ui.Picture 或直接缓存 TextPainter 列表,paint 里只负责按位置 drawImage。
另一个容易被忽视的点是 shouldRepaint。如果你的 painter 在 paint 里读取了 currentCm,却没在 shouldRepaint 里对比 currentCm 是否变化,那你在 setState 时 Flutter 也不会重绘,页面表现就是拖动毫无反应。排查时可以先打日志确认 setState 有没有触发,再看 painter 对比逻辑。
包裹层也别忘了:
dart复制RepaintBoundary(
child: CustomPaint(
painter: HeightRulerPainter(...),
size: const Size(64, 320),
),
)
RepaintBoundary 能避免尺子变化时把整个页面的其他部分一起重绘。这个优化虽小,但对滚动页面非常关键。
3. 生日输入:这个“熟透了”的组件,才是全页面最坑的地方
3.1 方案选择:原生日历、滚轮还是带格式的输入框
到了生日,第一直觉是用 showDatePicker 弹日历。但我在 OpenHarmony 设备上实测后,Material 风格的日历弹层能正常显示,问题出在交互层级比较多,如果页面本身已经有一个身高滑尺,再叠一层底部弹窗,用户会感觉很割裂。还有一点,默认的英文 showDatePicker 在中文 App 里需要额外配 locale,一旦 locale 没生效,日期选择器标题和星期显示就成了英文,观感很糟。
我也考虑过三列滚轮,但生日跨度的年份范围比身高宽得多,三列各自滚动手感又不一致,而且这种组件在 OpenHarmony 的滚动物理参数上刚踩过坑,没必要再冒险。
最后我选了“带格式约束的文本输入框”。用户直接输入类似 1995-08-19 的日期,输入过程中由 TextInputFormatter 自动插入连字符,失焦或点击保存时做完整校验。这个方案对 Flutter 自身能力依赖最低,不需要任何原生日期弹层,跨端表现反而最稳定。
3.2 DateTime.parse 的静默归一化陷阱
这里要重点讲一个极易出错的地方。如果你用 DateTime.parse('2023-02-30'),Dart 并不会给你一个格式错误,它会安静地把这种不合法日期“归一化”成 2023-03-02。我第一次写校验时直接照搬了常见的 parse 写法,测试人员随手输了 2 月 30 日,结果页面提示成功,后端收到的却是 3 月 2 日,年龄直接算错一天。
如果你拿 parse 之后的 DateTime 再传给后端,这个错误会一路静默传播,很难发现。正确做法是自己先拆出年、月、日三个数,校验月范围、日范围之后,再用一个特殊技巧判断当月实际有多少天:
dart复制bool isValidDate(int year, int month, int day) {
if (month < 1 || month > 12) return false;
if (day < 1 || day > 31) return false;
// DateTime(year, month + 1, 0) 表示“下个月的第 0 天”,即当月最后一天
final daysInMonth = DateTime(year, month + 1, 0).day;
return day <= daysInMonth;
}
DateTime(year, month + 1, 0) 是 Dart 里取某月天数的常用手段,它能自动处理平年 2 月 28 天、闰年 2 月 29 天,不用你自己去写闰年判断。你只要记住:解析用户输入前,先用这个函数拦一道。
3.3 闰年、未来日期和合理年龄:一个不落地校验
即便日期本身合法,也可能是一个存在于未来的日期。比如 2024-12-31 在当前时刻合法,但如果今天才 2024-06-01,那用户显然填错了。判断时用完整 DateTime 比较,会比只比较年份稳妥得多:
dart复制final birth = DateTime(year, month, day);
if (birth.isAfter(DateTime.now())) {
return '生日不能晚于今天';
}
这里用 isAfter(DateTime.now()) 而不是 !birth.isBefore(DateTime.now()),是因为同一天生日时,birth 是当天零点,DateTime.now() 已经过了零点,birth.isBefore(now) 为 true,所以当天生日会被正常接受,不会误伤“今天刚好过生日”的用户。
年龄上限也建议一起校验。健康管理场景里不是所有用户都愿意暴露精确生日,但生日字段一旦填了,就不能出现 300 岁这种明显脏数据。我按业务要求卡在 120 岁,超过则提示重新输入。计算方法很常规:
dart复制int calcAge(DateTime birth) {
final now = DateTime.now();
int age = now.year - birth.year;
final hasNotHadBirthday = now.month < birth.month ||
(now.month == birth.month && now.day < birth.day);
if (hasNotHadBirthday) age--;
return age;
}
建议把生日输入框的校验结果划分成几种提示:格式错误、日期不存在、未来日期、年龄超过合理范围。每一种都给出不同的文案,用户才知道自己错在哪里。如果把四种情况全混成一个“日期格式不正确”,体验会差很多。
下面是我在联调时习惯列出来的校验用例,测试时直接照这个跑:
| 输入 | 预期行为 | 实际校验点 |
|---|---|---|
| 2023-02-30 | 拒绝 | 2 月没有 30 号,必须靠 daysInMonth 拦截 |
| 2024-02-29 | 接受 | 2024 年是闰年,2 月 29 日存在 |
| 2100-02-29 | 拒绝 | 2100 年不是闰年,因为能被 100 整除但不能被 400 整除 |
| 今天是当天 | 接受 | 当天零点早于当前时间, |
