项目启动时,需求方只给了一句话:“做一个跨端的健康管理App,第一个版本要把目标选择功能做扎实。”我当时扫了一眼团队资源,心里就凉了半截——五个客户端开发,没有一个人碰过ArkTS,测试机里有OpenHarmony开发板,业务还要求明年覆盖Windows桌面端。这三个条件摆在一起,留给我的技术选型空间其实很小。
最终我选了Flutter for OpenHarmony这条不算热门的路线,用Provider管状态,核心交互组件全部自研。这篇文章把整个目标选择模块从业务建模、滚轮选择器实现、状态管理到真机适配的完整过程整理了一遍,给正在评估Flutter上OpenHarmony、或者要做健康类App目标设定模块的同行做个参考。你可以当成一篇实战记录,不必逐字照搬,但里面的选型逻辑和踩坑路径大概率能帮你少走几周弯路。
1. 为什么在OpenHarmony上做健康应用,我选了Flutter这条路
很多人一听“OpenHarmony应用开发”,第一反应就是用ArkTS写。这个思路在纯OpenHarmony场景里没错,但放到“跨端”“已有团队”“要快速迭代”这三个条件下,结论就不一定了。我这里不是要论证Flutter比ArkTS好,而是把我当时做决策的几个维度摆出来,你自己对照项目情况判断。
1.1 摆在面前的三个方案:ArkTS、Flutter、uni-app
当时我给团队列过一张对比表,站在“要在一个月内出一个可交互Demo”的角度,主要看四件事:团队上手成本、多端UI一致性、生态成熟度、性能预期。
| 方案 | 团队成本 | UI一致性 | 生态成熟度 | 性能预期 |
|---|---|---|---|---|
| ArkTS原生 | 全员从零学,排期至少多两周 | 最贴近系统风格 | 官方支持最完善 | 最好,尤其系统能力调用 |
| Flutter | 已有Dart经验,可直接开工 | 自绘渲染,跨端一致性好 | 插件生态庞大,但OpenHarmony适配需筛选 | 中上,低端设备需优化 |
| uni-app/RN | 需要补前端能力 | 依赖原生组件桥接,偏差较明显 | 一般,复杂交互支持弱 | 一般,桥接层有损耗 |
我选Flutter的核心原因不是性能,也不是“谁更流行”,而是“团队唯一能马上动起来的技术栈”。如果组里有三年ArkTS经验的专家,那ArkTS一定是更好的选择;但如果团队全是Flutter背景,又要同时覆盖Android、iOS和OpenHarmony,Flutter的性价比就非常明显。
1.2 Flutter for OpenHarmony 的适配现状,我踩过的信息差
OpenHarmony不是Android的一个分支,这一点必须说在前面。它的底层系统能力是用C/C++实现的,应用框架层有自己的声明式开发范式(就是我们常说的ArkTS)。Flutter能在上面跑,靠的是“自绘”这个特性:Flutter不依赖系统控件树,而是自己用Skia把每一帧画出来,所以只要引擎能嵌入到OpenHarmony的Ability壳里,UI部分就能跨端复用。
实际动手时,信息差主要集中在三块。第一,默认从pub拿到的Flutter SDK对OpenHarmony支持并不完整,需要切换到开源社区维护的适配分支,连engine带framework都要用对应的版本,版本对不上,新建项目后跑不起来是常态。第二,第三方插件不能无脑抄pub.dev,得逐个确认有没有OpenHarmony端实现,项目里最开始引的三个插件里只有一个能直接用,另外两个都要自己写Channel桥接。第三,社区文档里的示例大多基于特定开发板验证,换一台设备,屏幕、GPU、系统版本全都不一样,必须重新测。
这些情况都是我在当前适配版本下的实践认知,你切入的时间点更新,这些问题可能已经缓解,但“先验证再铺量”的原则不会变。
1.3 什么情况下我不建议你用Flutter跑OpenHarmony
如果功能强依赖系统权限和系统原生UI,比如复杂相机取景、系统级推送、对XTS认证要求高,这些场景直接用ArkTS效率更高。Flutter绕一层Platform Channel,不但通信有时延,很多系统API的封装还不完整,你在上层的任何一次“补丁式桥接”,最后都会变成维护负担。
另外,如果你的App对包体积极其敏感,也要慎重。Flutter引擎本身的体积摆在那里,比纯ArkTS应用大不少。团队如果全是Dart的门外汉,项目又急着交付,那也别为了“跨端”而跨端,先算一下学习成本和延期风险。
2. 目标选择模块的业务建模:先想清楚用户怎么“定目标”
健康管理App的“目标选择”看着是个小页面:用户选一个目标值,然后保存。但实际动手时,业务方会不断加需求,步数、喝水、睡眠、运动时长、体重,每种目标的范围、单位、步进值都不一样。如果不先把数据模型理清,后面每加一个目标类型,就要给整个页面补一套if else,那才是真的噩梦。
2.1 健康目标不只有步数:五类目标的差异化设计
我们的第一个版本规划了五类目标,它们的数据约束差异很大:
- 步数:整数,范围2000~50000,步进500,单位“步”,方向是“至少达到”。
- 饮水量:整数,范围4~20,步进1,单位“杯”,方向是“至少达到”。
- 睡眠时长:支持0.5步进,范围1~16,单位“小时”,方向是“推荐区间”。
- 运动时长:整数,范围10~180,步进10,单位“分钟”,方向是“至少达到”。
- 体重:浮点,范围30~200,步进0.1,单位“kg”,方向是“目标区间”。
这些差异不只是展示文案不同,它直接决定了滚轮选择器的取值范围、数字步进和单位后缀。如果你把步数和体重都当成“一个整数”来处理,那到体重目标的0.1步进就会让你重新改一遍交互逻辑。
2.2 GoalMeta与UserGoal:元数据与用户实际值的分离
我的做法是把“目标类型本身的配置”和“用户具体设置的数值”拆成两个模型。GoalMeta是静态元数据,一个类型一份,写死在配置里;UserGoal是用户实际保存的数据,会持久化到本地。
enum GoalType { steps, water, sleep, exercise, weight } class GoalMeta { const GoalMeta({ required this.type, required this.title, required this.unit, required this.min, required this.max, required this.step, this.direction = GoalDirection.atLeast, }); final GoalType type; final String title; final String unit; final double min; final double max; final double step; final GoalDirection direction; } class UserGoal { final GoalType type; final double value; final DateTime updatedAt; const UserGoal({ required this.type, required this.value, required this.updatedAt, }); }这样做的理由很简单:目标选择器只需要读GoalMeta,就能知道滚轮的范围、单位、步进和方向,保存时只写UserGoal。以后产品说“加一个心率区间目标”,我只需要新增一个GoalType、一条GoalMeta配置,页面代码一行都不用改。
2.3 选择页的状态流转:从“临时值”到“已生效”
交互流程是:用户点击首页目标卡片,底部弹出半屏选择层,滚动滚轮修改临时值,点“确定”提交,Provider校验并持久化,首页卡片刷新。中途任何时候取消,临时值直接丢弃。
这里我踩过一个坑:最开始临时值和生效值放在同一个Provider字段里,结果用户滑动滚轮还没点确定,首页卡片上的进度百分比就开始跟着变了。我赶紧把状态拆成tempValue和confirmedValue,确定按钮的回调里才做赋值。这个拆分看起来简单,却是整个目标模块最核心的状态设计,后面所有组件的刷新频率都依托于这个边界。
3. 核心交互实现:放弃官方Picker,我自研了一个滚轮选择器
目标选择页的核心交互就是一个“滚轮”。Flutter自带CupertinoPicker,功能上完全可用,但我在OpenHarmony上做了一轮真机对比之后,决定自己写一个轻量滚轮。这个选择不是炫技,而是被几个实际问题逼的。
3.1 为什么说“官方Picker水土不服”不是玄学
CupertinoPicker的问题是三个层面叠加起来的。第一,视觉上它内置了Cupertino主题的高亮渐变和模糊遮罩,放到HarmonyOS风格的健康应用里,一眼就能看出来是“硬搬过来的”,和整体界面格格不入。第二,性能上,这个模糊遮罩在拖动时开销不小,OpenHarmony开发板的GPU和主流安卓旗舰差距明显,实测滑动过程中掉帧。第三,扩展上,官方Picker对自定义列宽、中间刻度线、单位后缀、选中回调的支持都有限,我想在滚轮右侧加“步”或“小时”这样的单位,还得额外包一层Row,代价不小。
第三点尤其致命。目标选择器不是只做一个步数滚轮,睡眠要0.5步进,体重要0.1步进,这些需求用CupertinoPicker做,每一项都要去适配它的固定逻辑,不如直接自研一个受控组件,想怎么定制都行。
3.2 无限循环滚轮的实现思路
无限循环滚轮用了一个“大数取模”的技巧:ListView.builder的itemCount设置成一个很大的整数,比如10000,每个index对真实数据条数取模得到要显示的值。初始位置定位到中间某个值,这样用户无论往哪个方向滑,都摸不到边界。
static const int totalCount = 10000; static const int initialIndex = totalCount ~/ 2; final int realIndex = index % widget.data.length; final double value = widget.data[realIndex];初次居中时,用ScrollController的initialScrollOffset跳到initialIndex * itemHeight。有个细节:这个初始偏移在iOS和OpenHarmony上生效的时机不一样,最好在第一次布局完成后再设置,否则偶发闪屏。
3.3 惯性滚动、回中动画与手感调优
滚轮第一版做出来,手感很差:用户滑完松手,数字经常停在两个item中间,点“确定”保存的是错位值。解决办法是监听滚动结束事件,计算当前偏移量离哪个item最近,然后用animateTo做回中。
Future<void> _snapToNearest(ScrollPosition position) async { final target = (position.pixels / itemHeight).round() * itemHeight; _controller.animateTo( target, duration: const Duration(milliseconds: 220), curve: Curves.easeOutCubic, ); }手感的几个细节值得记一下:回中动画不要用linear,easeOutCubic会带一点“簧片吸过去”的质感;时长控制在180~260毫秒之间最自然,太慢显得拖沓,太快又生硬。itemHeight我定的是56,原因是中文数字在这个高度下可读性最好,点击区域也够大。
3.4 选中值的反馈细节
光有回中还不够,用户滑动时需要一个“当前到底选中了没”的即时感知。我加了两层反馈。
第一层是视觉:滚轮中轴线两侧做透明度渐变遮罩,上下边缘自然淡出,让视觉重心落在中间的高亮线上。第二层是触觉:选中值变化时调用系统震动反馈,OpenHarmony上通过MethodChannel调系统的震动能力,如果适配层没有实现这个接口,就降级为无反馈,不能让异常抛到业务层。
还有一个容易被忽视的细节:数字要用等宽字体特性,改一下FontFeature.tabularFigures(),否则滚轮从999滚到1000时,数字宽度变化会让整个列表“跳一下”,非常影响体验。
4. 目标选择中的状态管理:Provider模式与组件通信实战
滚轮选择器做出来后,下一个问题是“状态往哪放”。这个模块的参与者不少:半屏弹层里的滚轮、底部确定和取消按钮、首页目标卡片、进度百分比。四个子模块都要感知目标值的变化,但变化节奏又完全不同,这里就是flutter provider这类状态管理工具该上场的地方。
4.1 为什么选Provider而不是Riverpod或Bloc
选型时纠结过一阵子。Riverpod的编译期安全和可测试性确实更好,但概念多,团队里有人刚接触Dart,我不想让一个“目标选择”模块引入六七个新概念;Bloc的样板代码多,一次滚轮选择要配事件、配状态、配Bloc类,明显重了。Provider正好卡在中间:一个ChangeNotifier加一层Consumer,就能支撑这个模块的全部需求。
我自己的选型标准一直很简单:状态工具不是越复杂越好,而是“刚好够用,队友能看懂”。目标选择模块的状态范围有限,Provider是性价比最高的选择。如果哪天模块膨胀到需要更严格的状态隔离,再迁移到Riverpod也不迟,因为现在的业务逻辑都收在Provider里,替换成本可控。
4.2 Provider在目标模块中的三层结构
我用一个GoalProvider把“读取存储、临时值、确认值、校验、持久化、通知变更”全包了,页面上通过不同的Consumer粒度控制刷新范围。
class GoalProvider extends ChangeNotifier { GoalProvider(this._storage); final Storage _storage; final Map<GoalType, UserGoal> _confirmed = {}; final Map<GoalType, double> _temp = {}; double tempValueFor(GoalType type) => _temp[type] ?? _confirmed[type]?.value ?? 0; UserGoal? confirmedFor(GoalType type) => _confirmed[type]; void updateTemp(GoalType type, double value) { _temp[type] = value; notifyListeners(); } Future<bool> confirm(GoalType type) async { final value = _temp[type]; if (value == null) return false; if (!validate(type, value)) return false; _confirmed[type] = UserGoal( type: type, value: value, updatedAt: DateTime.now(), ); _temp.remove(type); notifyListeners(); await _storage.saveGoal(_confirmed[type]!); return true; } }这里有一个关键设计:临时值变化时,notifyListeners会把所有监听者都通知一遍,所以我在不需要响应临时值的区域用context.select只监听confirmed值,滑动滚轮时只有选中数字和确定按钮状态在变,首页卡片不会跟着每帧重建。
4.3 组件通信:滚轮组件怎么把“当前值”告诉外面
滚轮选择器我设计成自包含组件,对外只暴露onSelectedChanged回调,这样它既可以用在目标选择弹层里,以后做睡眠提醒时间选择、喝水提醒间隔也能复用。
组件内部的滚动状态用ValueNotifier管理,滚动事件通过NotificationListener向外上报,每次回中结束之后才回调数值。这一点是flutter组件通信里容易被忽略的坑:不要每次滚动都通知外层,而是“稳定下来才通知”。否则外层会因为高频状态更新反复build,掉帧问题会从列表内部扩散到整个页面。
4.4 弹层生命周期的Provider隔离
底部弹层每次打开,是新建一个Provider作用域,还是复用全局实例?我一开始图省事,直接复用全局,结果上次没点确认的残留临时值,下次打开弹层时又冒出来了。后来改成弹层打开时重置temp,关闭时销毁弹层自己的Provider scope,确认后的值才同步到全局。
这个“弹层生命周期隔离”的经验,对于所有底部弹窗类交互都适用。临时状态不该活到弹层关闭之后,否则各种“灵异复现”都会变成排查噩梦。
5. 数据落库与目标生效链路
目标选择完成后,系统要记住用户的目标,并且让首页、统计页、运动记录页都能感知到变化。这是整个模块从“能选”到“能生效”的关键一步,也是经常被教程忽略的部分。
5.1 持久化:shared_preferences在OpenHarmony上的兼容问题
Flutter里最常用的本地存储是shared_preferences,但OpenHarmony不是所有平台能力都自动兼容。我为了不把代码写死,封装了一层Storage接口,底层是shared_preferences还是OpenHarmony侧的文件存取接口,都可以替换。
abstract class Storage { Future<double?> getGoalValue(GoalType type); Future<void> saveGoal(UserGoal goal); }实际适配时,我先在目标开发板上跑了一个最小验证:写入上百个key,连续读写上千次,杀掉进程后重新启动读值,确认数据不丢,才敢把模块接进来。这里特别提醒:无论你用哪个存储方案,都要以“进程被杀后重启”为标准做冒烟测试,模拟器上正常不代表真机持久化可靠。
5.2 目标合理性校验:不是给用户设限,是给数据兜底
目标值的校验规则不是拍脑袋定的,每一条背后都有原因:
- 步数:限2000~50000。太低了没有运动激励效果,太高了大部分用户根本完不成,反而制造挫败感。
- 饮水量:限4~20杯。低于4杯不叫健康管理,高过20杯反而不安全。
- 睡眠时长:限1~16小时。低于1小时和高于16小时已经是极端异常值。
- 运动时长:限10~180分钟。运动记录页的图表依赖这个区间做坐标轴缩放。
- 体重:限30~200kg。这个范围偏差过大时,BMI计算和趋势曲线的Y轴都会畸形。
校验逻辑放在Provider.confirm里,失败时返回错误文案并显示在弹层顶部。为什么不直接把min和max写死在滚轮范围里,还要再校验一遍?因为用户以后可能通过语音助手、手表端改目标,入口不止一个,中心校验是最后一道闸。
5.3 目标生效后怎么通知其他模块
项目一开始用过eventBus,后来发现模块多了之后事件满天飞,出了问题不知道是谁发的、谁该收。后来我改成直接订阅Provider:首页进度卡片订阅GoalProvider,目标变化时重新计算进度百分比;统计页需要历史轨迹,所以在confirm时额外写一条goal_change_log记录,不靠事件广播。
这里有个我自己比较满意的设计:睡眠目标的值会影响次日的“建议起床时间”,但起床时间组件不直接监听目标变化,而是通过一个派生Provider计算“目标区间与当前时间的差值”。这样目标模块和作息建议模块完全解耦,谁都不用知道对方的存在。
5.4 多目标联动的边界
当目标数量变多,联动关系会出现:睡眠时长影响建议起床时间,体重目标影响BMI区间展示,步数和运动时长一起决定每日消耗预估。我的原则是,这些派生关系全部放到业务层,不在目标选择器的交互层硬编码。目标选择器只负责“把正确范围内的值选出来并保存”,至于这个值会影响什么,由上层模块自己订阅,各取所需。
6. 真机与模拟器之间的适配问题记录
这部分最碎,但每一条都是真金白银踩出来的。我在模拟器上把目标选择模块调得顺顺当当,一上OpenHarmony真机,翻了两次车之后才明白:这个平台的真机差异比Android碎片化还要隐蔽。
6.1 字体缩放导致的选择器文本错位
真机系统字体被调成“大号”后,MediaQuery的textScaleFactor会变化,我固定写的itemHeight=56就装不下一行数字了。修复思路是把itemHeight改成max(56, 56 * textScaleFactor)动态计算,同时滚轮的itemCount和初始偏移都要基于动态itemHeight重新算。这个bug在模拟器默认字体下永远不会出现,所以真机测试不可跳过。
6.2 安全区导致的按钮误触
OpenHarmony真机底部有返回手势条,半屏弹层的确定和取消按钮如果直接放在bottom: 0,手势条会遮挡一部分。用SafeArea包裹还不够,必须把弹层底部留出和SafeArea等高的空白区域,并且把“取消”按钮放在离手势条更远的位置。真机实测发现,如果按钮太靠近手势区域,用户想按“取消”时经常会触发返回手势,误触率高得离谱。
6.3 Impeller渲染引擎与OpenHarmony图形栈的兼容
Flutter新的Impeller渲染引擎性能确实好,但它在OpenHarmony上的适配进度和Android、iOS并不完全同步。我试过在新版Flutter上开启Impeller,结果开发板上列表滑动偶发花屏,最后只能切回Skia后端先稳住版本。提醒一句:如果你们项目遇到渲染异常,先检查是不是渲染后端的问题,别一上来就改业务代码,改来改去发现方向错了。
6.4 滚动掉帧的排查路径
目标滚轮在开发板上滑起来会卡,我用Flutter DevTools的Timeline看了一眼,主要耗时在build阶段的字符串格式化——每滚一帧都在重新创建DateFormat和拼接单位字符串。优化成缓存格式化实例后,帧率稳定多了。
另一个收益很大的优化是把滚轮的选中数字和外层卡片拆成不同的刷新通道,数字用局部ValueNotifier,卡片用Provider,不要一个notifyListeners把所有组件都带起来。外层重建频率下降后,整个弹层的交互明显跟手,这个方法在任何一个高刷新页面都适用。
做这个目标选择模块,回头看我最大的感受是:Flutter for OpenHarmony这条路,难的不是Flutter本身,而是“你默认以为没问题”的地方几乎都会出问题。所以我现在做任何跨端功能,都会先在设计里留一层抽象——存储能换、渲染后端能切、组件能自研。哪怕这些抽象在项目初期看着像“过度设计”,等真机问题扑过来的时候,你会庆幸当初没有把所有代码都揉在一起。
最后分享一个实操小技巧:把目标选择器的itemHeight、回中动画时长、滚轮最大列宽这些基础参数,设计成语义化常量放在主题扩展里。以后产品如果要求从滚轮改成滑杆,或者目标类型从五种扩到十种,你只需要改配置,不需要重写交互层。这种“参数配置化”的思路,放在任何跨端平台上都成立。