1. 为什么要把shadcn_ui全家桶搬上OpenHarmony,又是为什么先从复选框动手
事情是这样的:上个月我们团队接到一个迁移需求,要把一个原本跑在 Android 和 iOS 上的 Flutter 业务应用,完整搬到 OpenHarmony 设备上。业务层倒没什么问题,Dart 代码基本能直接复用,真正折腾人的是那一堆三方库。尤其是 UI 组件库,我们当时用的是 shadcn_ui ,设计风格我很喜欢,组件也够现代,但它从设计之初就没考虑过 OpenHarmony 这个运行环境,能不能跑、跑起来什么样,全是未知数。
肯定有人会说,OpenHarmony 上跑 Flutter 应用,不就是把 APK 换成 HAP 的事吗?真不是。Flutter 在 OpenHarmony 上运行时,底层渲染引擎、平台通道、文本输入、手势识别这些环节都有属于自己的一套实现。如果组件库本身只依赖 Flutter 标准控件,那还好说;一旦用到了平台相关的能力,比如系统字体、系统震动、水波纹反馈,就可能出现“Android 上正常,OpenHarmony 上失灵”的典型症状。
1.1 shadcn_ui在Flutter侧的价值:不止是“套壳”
先给不熟悉的读者补个背景。shadcn_ui 是 Flutter 社区里一个比较新的设计系统实现,它移植自前端圈很有名的 shadcn/ui。它和很多传统组件库不一样的地方在于:组件源码完全开放、不搞黑盒封装,你直接把组件复制到项目里改都行;样式通过主题 Tokens 驱动,改一个颜色变量,整站组件风格跟着变。这种“给你积木,不给你成品城堡”的思路,让它在开发者里口碑很好。
我们项目里用了它的一整套表单组件,包括输入框、选择器、开关、单选按钮和复选框。和其他组件相比,复选框看起来是最不起眼的一个,但我当时坚持拿它当适配的“排头兵”,原因很简单:
- 它状态多:选中、未选中、半选(indeterminate)、禁用、错误、聚焦,几乎覆盖了表单组件的所有状态组合。
- 它交互深:既要支持点击切换,又要支持键盘空格触发,还要有焦点边框、涟漪反馈、动画过渡。
- 它体积小:核心代码量不大,适合在移植过程中做“逐行对照调试”,出了问题好定位。
- 它链路全:从组件内部状态管理,到外部的 FormField 表单集成都牵扯得上,适合验证一套通用适配方案。
说白了,把复选框完整跑通,就等于把组件库在 OpenHarmony 上的「渲染链路 + 交互链路 + 语义链路」都验证了一遍。后面去适配 Switch、Radio、Input 这些组件时,基本就是套模板。所以这一篇我不会只讲代码怎么抄,会更侧重讲讲这个过程中踩过的坑和验证过的方法。
1.2 复选框真没看上去那么简单
很多人第一次写复选框组件时,都以为这就是一个Checkbox(value, onChanged)的事。但当你开始认真做一套可以在生产环境里用的复选框时,问题会越来越多:
| 维度 | 需要覆盖的点 |
|---|---|
| 状态 | checked / unchecked / indeterminate / disabled / error / focused |
| 尺寸 | sm(小)、md(中)、lg(大),以及不同缩放系数下的表现 |
| 视觉 | 边框颜色、背景色、圆角、勾选图标、半选横杠、过渡动画 |
| 交互 | 点击切换、按键切换、触摸反馈、悬停反馈、点击区域命中率 |
| 语义 | 屏幕阅读器朗读状态、label 关联、组内成员关系 |
| 表单 | FormField 集成、初始值、校验错误、状态重置 |
这还是在普通 Flutter 平台上。到了 OpenHarmony 上,还要再叠加一层平台差异。比如涟漪效果能不能由系统绘制、焦点遍历是否遵循遥控器方向键逻辑、无障碍服务有没有异步朗读的坑。所以说,复选框这个组件,是小切口、大文章,把它做好了,整个表单体系的地基就稳了。
2. 适配前的准备:SDK选型、依赖体检和冒烟用例
2.1 OpenHarmony的Flutter SDK到底该怎么选
适配 OpenHarmony 的第一步,是要搞明白“你到底在用哪个 Flutter SDK”。这个我一开始也懵了,因为网上资料很杂,有说用标准 Flutter SDK 的,有说必须用某组织维护的 ohos 分支的。
实际情况是:OpenHarmony 的 Flutter 支持,目前主流方案是基于 Flutter 官方版本做了一层引擎适配,SDK 需要从对应仓库拉取,并和 DevEco Studio 里的 OpenHarmony SDK 配套使用。也就是说,你本地的flutter命令指向哪个版本,直接决定了你的应用能用到哪些 Flutter API。
我的建议是不要自己猜,直接看 OpenHarmony 官方适配文档里指定的 Flutter 版本号,然后严格按那个版本 checkout。一个常见错误是用最新 Flutter 写代码,结果 OpenHarmony 分支还在较老的版本上,导致MaterialStateProperty、WidgetState之类的 API 名对不上,编译报错一片红。
我们当时统一的做法是在项目根目录放一个.fvmrc,把flutter版本钉死。一开始这么做觉得麻烦,后来发现,团队里不同人本地环境不一致,光“在我电脑上能跑”这句话就浪费了好几天。钉死版本之后,至少能保证“大家拉下来都一样”。
2.2 三方库适配前的“依赖体检”
在把 shadcn_ui 加入 OpenHarmony 工程之前,我花了一些时间对它做了个“依赖体检”。这一步建议不要省,否则后面很容易出现“编译过了但运行必崩”的尴尬局面。
体检内容大致有三项:
- 是否纯 Dart 实现:如果你的三方库依赖了原生插件,比如用
MethodChannel调 Android 原生代码,那在 OpenHarmony 上就要确认对应插件是否有 ohos 实现。shadcn_ui 很幸运,它基本是纯 Flutter 控件组合,底层只依赖flutter/material.dart这一类官方库,这大大降低了迁移成本。 - 是否使用了高版本 Flutter API:检查一下库的
pubspec.yaml里声明的environment: sdk和flutter版本要求。比如某组件用了较新的Color.withValues(),而你 OpenHarmony 分支还是老版本,就会报找不到方法。 - 是否有平台路径硬编码:有些库内部写了
Platform.isAndroid这样的判断,在 OpenHarmony 上运行时会落到 else 分支,表现可能异常。好在 OpenHarmony 的 Flutter Engine 对Platform的判断结果比较接近 Android,但建议还是在关键逻辑处打印日志确认一下。
做完这三项,我才把 shadcn_ui 放进了pubspec.yaml。这里多说一句:一定不要直接在依赖里写git仓库地址的最新分支,最好切到一个可复现的 tag。因为这种库迭代很快,今天能编译的版本,明天可能就推了新 commit 进 main,然后整个工程就炸了。
放进去之后执行:
flutter pub get flutter pub deps --style=compact用pub deps看一眼依赖树,确认没有引入意外传递依赖,比如某个库偷偷依赖了shared_preferences或path_provider,这种原生插件在 OpenHarmony 上即使能编译,也未必有对应实现。
2.3 先跑冒烟demo,别让组件库背了平台的锅
依赖装好之后,我没急着把整个业务页面搬过来,而是先建了一个极其简单的冒烟工程。页面里只有一个 shadcn_ui 的复选框,旁边放了几个日志按钮。有人可能觉得这也太保守了,但恰恰是这个“寒酸”的 Demo,帮我避开了后面一堆复杂调试。
冒烟 Demo 要覆盖的场景:
- 空白页面下渲染单个复选框,看能否正常显示默认态。
- 切换 checked 状态,看动画和回调是否触发。
- 改动
ThemeData的colorScheme,看主题色是否同步生效。 - 用文字缩放 1.3 倍,看布局是否溢出。
- 使用键盘 Tab 聚焦,看焦点边框是否出现。
- 打开无障碍服务,触摸组件,听语音播报。
这套冒烟用例执行完,基本能判断“组件库本身能不能在 OpenHarmony 上活下来”。如果这些基础场景都没问题,再往业务里集成才是有意义的。如果一上来就塞进真实页面,遇到问题你根本分不清是组件适配问题、页面布局问题还是 OpenHarmony 渲染差异的问题,排查成本翻倍。
3. 复选框组件的实现:从shadcn设计稿到Flutter Widget
3.1 状态模型:从shadcn的布尔值扩展出三态
shadcn/ui 在 Web 端的复选框状态,用的是checked字段,类型为boolean | 'indeterminate'。也就是说,选中、未选中、半选这三个状态,用这一个字段就表达了。到了 Flutter 侧,我延续了这个思路:用bool?来表达。
true代表选中,false代表未选中,null代表半选。这样设计的最大好处是,外部传入状态时几乎不需要额外构造复杂对象,也不用担心“半选和未选中到底谁是默认值”这种编码歧义。
组件对外接口我定义为:
class ShadcnCheckbox extends StatefulWidget { const ShadcnCheckbox({ super.key, this.checked, this.onChanged, this.disabled = false, this.size = ShadcnCheckboxSize.md, this.label, this.errorText, this.semanticLabel, }); }这里用了受控组件的写法:checked由外部传入,onChanged把新状态抛出去。这样做的好处是状态机的控制权在调用方手里,方便和其他 Form 字段联动。后面如果要做非受控版本,只需要在组件内部维护一个bool? _innerValue,初始化时从 widget 传入,点击后再同步回外部即可。
状态转换的逻辑只有四条:
- 点击时,当前为
false或null,切换为true(选中)。 - 当前为
true,切换为false(取消选中)。 - 半选状态下点击,一般切换到
true,而不是false。这个和前端的交互习惯一致。 - 当外部传入
null时,组件只展示半选图形,不响应点击切换。
第二步看起来简单,但真写代码时容易忽略一个边界:点击组件后,动画还没播完,用户又点了第二下。如果不做防抖,就会出现动画反复重启、状态闪烁的情况。后面我在坑位篇会细讲这个问题。
3.2 外观与视觉状态:尺寸、圆角、边框、背景
shadcn/ui 复选框的视觉特征很鲜明:小圆角、1~2 像素的边框、选中后填充主色调、白色勾线。到 Flutter 里我用AnimatedContainer作为背景容器,内部嵌套自绘图标,这样背景色、边框色、圆角这些属性的变化都能自动过渡。
尺寸规格我参照 shadcn 的约定,做成了三档:
| 档位 | 尺寸(逻辑像素) | 圆角 | 边框粗细 | 图标大小 |
|---|---|---|---|---|
| sm | 16 | 4 | 1.2 | 10 |
| md | 20 | 5 | 1.5 | 13 |
| lg | 24 | 6 | 1.8 | 16 |
在 OpenHarmony 设备上,逻辑像素和 Android 一样是dp,所以这些数值我会在代码里写成EdgeInsets和SizedBox的组合,而不是直接写double,避免在高分屏上出现发虚。
边框颜色和背景色不是固定值,而是从Theme.of(context).colorScheme里取:
final borderColor = state == true ? colorScheme.primary : (errorText != null ? colorScheme.error : colorScheme.outline); final bgColor = state == true ? colorScheme.primary : Colors.transparent;这个设计保持了 shadcn 订阅主题的哲学——组件不自己对颜色写死,全部走主题通道。适配 OpenHarmony 时,设备厂商可能会对默认字体、系统强调色有自己的定义,我们只需要在入口统一设置ThemeData的颜色即可。
3.3 勾选动画与自绘图标
勾选动画在做 Web 端时,是 CSS 的transition就能解决的事情,到 Flutter 里需要自己控制。我这里选择在AnimatedContainer之上,再叠加一个CustomPaint,用画笔绘制勾选图标。
好处有两个:
- 避免额外引入一张图标图片资源,OpenHarmony 打包时不会因为资源格式问题报错。
- 在动画过程中,可以通过
Animation<double>对路径的绘制进度做插值,实现“勾从左上角画到右下角”的逐帧效果。
我自定义了一个_CheckmarkPainter,核心逻辑大致长这样:
class _CheckmarkPainter extends CustomPainter { final double progress; // 0.0 ~ 1.0 final Color color; final double strokeWidth; @override void paint(Canvas canvas, Size size) { final paint = Paint() ..color = color ..strokeWidth = strokeWidth ..strokeCap = StrokeCap.round ..style = PaintingStyle.stroke; final path = Path(); // 从左上角到右下角的勾形路径 path.moveTo(size.width * 0.22, size.height * 0.5); path.lineTo(size.width * 0.45, size.height * 0.7); path.lineTo(size.width * 0.8, size.height * 0.3); // 按进度截断路径 final metrics = path.computeMetrics().first; final extractPath = metrics.extractPath(0.0, metrics.length * progress); canvas.drawPath(extractPath, paint); } }progress的值由AnimationController提供,选中时从 0 涨到 1,取消选中时从 1 落到 0。注意我这里说的是“勾的绘制进度”,不是组件透明度。实际观感上,勾是“一笔画完”的,比整体淡入淡出要自然得多。半选状态的横杠更简单,一条直线,从 30% 画到 70%,不需要做逐帧动画,直接静态绘制即可。
关于动画时长,shadcn/ui 的 Web 端大概在 150ms 到 200ms 之间。我在 Flutter 侧用的是 160ms,曲线选Curves.easeOutCubic,起手快、收尾柔。太快会显得生硬,太慢在列表批量勾选时会有明显的拖沓感。
3.4 受控与非受控的取舍
这里我多说一点设计和代码结构的取舍。既然组件叫ShadcnCheckbox,它既要能当独立组件用,也要能塞进Form里做字段校验。
我采用的是双模式:
- 外部传
checked,组件作为完全受控组件。此时外部负责状态保存,组件只负责显示和回调。 - 外部不传
checked,组件内部自己维护一个状态,首次点击后自己切换。此时外部只需要关心onChanged时做了什么事。
这样设计,是为了兼容两种业务场景。比如设置页面里一排开关/复选框,往往只需要本地状态,没人愿意为每个复选框单独写一个setState;而表单提交页面,又必须把所有字段值集中到一个FormState里做校验和提交,这时候就需要受控。
受控模式下,当外部传回来一个和内部缓存不一致的值时,组件要在didUpdateWidget里同步一次:
@override void didUpdateWidget(ShadcnCheckbox oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.checked != widget.checked) { _state = widget.checked; _animationController?.forward(from: 0.0); } }这个同步非常重要,否则会出现“点击后回调出去了,但界面没有变化”的假死状态。我在 OpenHarmony 真机上就遇到过几次,业务侧把值更新到了 store,但组件build时拿到的还是旧值。后来发现就是漏了didUpdateWidget的状态同步。
4. 交互细节在OpenHarmony上的调整:涟漪、焦点和无障碍
4.1 触摸反馈的实现与平台差异处理
复选框本身是个小控件,但如果点击后没有任何视觉反馈,用户会怀疑自己“到底按到了没有”。shadcn 的设计里,点击时有一个淡淡的涟漪扩散效果。在 Flutter 标准控件里,这通常用InkWell或InkResponse实现。
一开始我直接用InkWell,在 Android 上表现完美,但跑到 OpenHarmony 真机上发现涟漪不显示。排查了很久发现,OpenHarmony 的 Flutter 分支对Material控件的ink_well绘制支持有不一致的地方——某些情况下,Ink会画在Material底下,被组件背景盖住。
我的解决思路有两层:
- 检查
InkWell外层是否包了Material。有的页面因为主题装饰,InkWell的父级不是Material,涟漪根本没地方画。 - 如果你的字体缩放、形状遮挡频繁出现问题,可以直接改用
AnimatedContainer+onTap手势,自己做一个 120ms 的波纹透明度动画,代替InkWell。虽然少了一点物理波纹效果,但胜在稳定可控。
我最终在 OpenHarmony 适配版里,把涟漪效果改成了“按下时背景变深 + 松手时恢复”的简洁反馈。实际观感不差,反而更符合 shadcn 那种扁平克制的风格。这里给个建议:如果想保证跨端一致性,尽量少依赖 Material 内部的水波纹实现,自己控制视觉反馈反而更省心。
4.2 焦点管理:让遥控器和键盘都能用
OpenHarmony 设备并不只有手机,还有电视、平板、带键盘的混合形态设备。这就意味着复选框不能只支持鼠标点击和手指触摸,还要支持键盘 Tab 聚焦、空格选中、回车触发,以及遥控器方向键在组件之间移动焦点。
Flutter 侧实现焦点管理,主要是围绕FocusNode和FocusTraversalGroup。我在ShadcnCheckbox内部创建了一个FocusNode,并在build时用Focus包裹,监听按键事件:
Focus( focusNode: _focusNode, onKeyEvent: (node, event) { if (event is KeyUpEvent) return KeyEventResult.ignored; final key = event.logicalKey; if (key == LogicalKeyboardKey.space || key == LogicalKeyboardKey.enter) { _handleTap(); return KeyEventResult.handled; } return KeyEventResult.ignored; }, child: ... )焦点边框这里有个讲究:shadcn 的焦点效果不是把一个高亮色圆形直接盖在上面,而是在组件外围出现一圈浅色描边。我用一个AnimatedContainer的border来呈现,过渡时间 120ms,颜色从透明渐变为主色调。当_focusNode.hasFocus为 true 时,边框色变成主色,并加 1px 的外圈。这样在遥控器模式下,用户能清楚看到“当前焦点在哪”。
这里简单证明一下为什么“焦点”值得单独立项做。在真实场景里,用户用遥控器操作 OpenHarmony 电视应用时,Tab 本来就是按某种遍历顺序跳的。如果我们组件没有正确申请焦点,用户按方向键时焦点会直接跳过复选框,看起来就像复选框“不存在”一样。我在调试时就把方向键跳转的逻辑打印出来了,发现 OpenHarmony 分支的遍历顺序和 Android 不完全一致,需要配合FocusTraversalGroup的descendantsAreFocusable属性来约束子节点。
4.3 无障碍语义:语义合并和状态播报
Flutter 的无障碍支持,依赖的是Semantics节点。复选框这种“可点击 + 可选中”的组件,一定要在语义树上正确表达两个属性:button或者checkbox的角色,以及checked状态。
我在组件外层套了一个Semantics:
Semantics( container: true, label: widget.semanticLabel ?? widget.label, toggled: _state == true, enabled: !widget.disabled, onTap: widget.disabled ? null : _handleTap, child: ... )这里要注意两个细节:
- 勾选框和 label 文本要是一个整体。如果你把文本放在复选框旁边但没有合并语义,读屏软件会把它读成两个控件:“未选中,复选框”“保存设置”。用户根本不知道这俩是一回事。正确做法是把 label 放进复选框的
Semantics.label,或者用mergeSemantics包住整个行。 - 点击时要有状态变化播报。在 OpenHarmony 上做无障碍测试时,我发现切换状态后,读屏软件有时候播报不及时,原因是我在
_handleTap里没有主动触发语义更新。解决办法是在setState之前,先调用_semanticsOwner的notify或者SemanticsService.announce,强制通知一次。Flutter 框架层大多数时候能自动处理,但自定义控件加一层保底是值得的。
无障碍这一块,肉眼是“看不到”的,但它决定了这个组件能不能用于正式的无障碍验收。我在适配时专门用 TalkBack 和鸿蒙侧的无障碍服务都测过一轮,至少保证:焦点读“复选框”,状态读“已选中/未选中/部分选中”,点击后能及时更新朗读内容。
5. 实测踩坑复盘:五个让组件“半废”的隐藏细节
5.1 坑一:“能点不能勾”——手势冲突的排查链路
第一个坑很诡异:复选框在页面上能按出涟漪,但状态就是不变。当时我一度怀疑是 OpenHarmony 的GestureDetector识别问题,后来冷静下来,按照最原始的办法排查。
我在组件外层临时加了一个Listener,把PointerDownEvent和PointerUpEvent都打日志。结果发现:按下和抬起事件都有输出,但组件内部的onTap没有触发。
这说明手势被判断成了“取消”。再往下看,发现外层页面有一个竖向ListView,复选框正好在列表项里。因为列表可以滚动,用户在点击复选框时,只要手指有 2~3 像素的偏移,GestureDetector 就认为这是“滚动”而不是“点击”,从而取消点击。Android 上因为手势容忍度稍高,表现不明显;OpenHarmony 的 Flutter 分支在手势竞技场里的判赢策略更严格,于是问题暴露了。
解决方案:把复选框的behavior从HitTestBehavior.deferToChild改为HitTestBehavior.opaque,并确保自身的点击区域是一个足够大的正方形。同时配合Material的clipBehavior设置,避免子节点让出命中区域。
这个坑让我得到一个教训:在 OpenHarmony 适配中,遭遇“点击不响应”时,先不要怀疑平台没这个能力,第一时间把手势日志打开,大概率问题是手势竞技场判定规则不同,而不是功能缺失。
5.2 坑二:勾选动画瞬变,没有过渡效果
第二个问题是动画。Android 上切换状态时,勾线会从左上角一笔画到右下角,加上背景颜色过渡,非常顺滑。但到了 OpenHarmony 真机上,整个动画像是被“跳过”了,状态直接切换,画面闪一下。
排查之后发现,问题出在动画控制器和主线程帧调度的配合上。
我的代码里,_handleTap里同时做了两件事:调用setState更新状态颜色,又调用_animationController.forward(from: 0)。在标准 Flutter 上,动画在第一帧就会更新值,然后逐帧渲染。但 OpenHarmony 分支上,如果状态变化触发了重构,动画控制器的起始帧可能没有正确补偿,外部状态直接跳到了目标值,导致动画看起来像被截断。
解决方案是:把颜色和图标的“目标状态”也从动画控制器统一驱动,而不是让它们依赖setState。比如背景色不再用AnimatedContainer的隐式动画,而是用AnimationController驱动一个ColorTween。这样,无论外部setState多频繁,视觉变化都严格跟动画帧走,不会出现“状态已更新、动画未播放”的错位。
这里值得多写一句:当你发现自定义控件在跨端动画表现不一致时,优先考虑把所有的视觉变化集中到一个 AnimationController 上,而不是混合使用隐式动画和显式动画。隐式动画依赖 widget 重建,显式动画依赖 ticker,两者混用时,不同平台的帧调度顺序不一致,就会出现这种“瞬变”。把视觉统一到一种机制里,虽然代码量多一点,但跨端表现会稳定很多。
5.3 坑三:文字缩放后布局溢出
OpenHarmony 上很多设备开了字体放大功能,无障碍模式下字体可以放到 1.3 倍甚至 1.5 倍。复选框本身尺寸是固定的,但旁边的 label 文本一旦放大,行高会变大,原本排列好的 Row 布局直接溢出。
这个问题在真机调试里特别明显:我固定了复选框的SizedBox(width: 20, height: 20),label 的字体从 14sp 放大到 20sp 时,label 的高度超过了 20,Row 的交叉轴对齐方式如果设置成center,倒还能看;但如果 label 是两行文本,就会和复选框重叠。
解决方案是放弃“复选框固定 20px 高 + label 单行居中”的思路,改成“container 高度随内容增长”的弹性布局。复选框自身保持Align.center,外层用IntrinsicHeight包裹整行。不过IntrinsicHeight在长列表里会让布局计算变慢,我最终用的是ConstrainedBox+Row(crossAxisAlignment: CrossAxisAlignment.start)的组合,让行高由 label 的文本决定,复选框在顶部对齐,视觉上反而更接近 shadcn 的原版风格。
还有一个隐藏得很深的溢出点:TextScaler放大后,勾选图标在富文本里的 line-height 也会被放大,导致图标视觉上比复选框“冒出来”一点。解决办法是图标不要放在文本流里,用Positioned或者Stack单独控制位置。这个细节如果不是真机放大字体,根本发现不了。
5.4 坑四:焦点框“画偏了”
实现焦点边框时,我在AnimatedContainer的border属性里画了一圈 1px 的边框。Android 上没问题,但 OpenHarmony 真机上,焦点框的位置明显偏离组件边缘大概 1~2 像素。
排查了很久,最后发现是Material的shape和Clip的默认行为不同:当外层Material有圆角裁剪(clipBehavior: Clip.antiAlias)时,内部组件的外边框会被裁掉一部分,导致看起来焦点框和内边框偏移。
解决方案:把焦点边框放在DecoratedBox的 foregroundDecoration 上渲染,而不是放在border上。这样焦点边框绘制在内容之上,不会被裁剪;同时用Padding隔开 2 像素,保证焦点框和组件自身边框不重叠。改完之后,焦点框的视觉位置才和设计稿一致。
这个坑给另一个启示:不要把“裁剪”和“描边”混在一个容器上做。裁剪是为了内容不外溢,描边是边界提示,两者职责不同。如果条件允许,描边用foregroundDecoration绘制更稳妥。
5.5 坑五:快速连点导致状态回跳
第五个坑几乎是我快做完才发现的高频问题:用户快速点击复选框三下,最后状态竟然是“未选中”,而不是“选中”。我看回掉日志,发现 onChanged 确实被调用了三次,第二次传的是 true,第三次传的是 false——组件在这种“动画还没结束,状态又变了”的节奏下,内部缓存和外部状态失步了。
这个问题的根因在于,我在点击处理里直接拿内外部状态的对比结果来决定下一个状态:
final nextState = (widget.checked ?? false) ? false : true;如果外部状态在动画期间没有及时更新到组件里,第二次点击读到的是旧值,自然就“回跳”了。
解决方案是给点击处理加一个简单的防抖:如果上一次点击的状态切换动画还没播完,就忽略新的点击。我用了_lastTapAt时间戳记账,间隔小于 180ms 的直接 return。这对正常用户操作没影响,但能彻底杜绝快速连点时的状态错乱。
这个场景后来在演示时被我单独拿出来讲,因为很多开发者都会忽略“快速交互”这一类边界情况。Android 上因为点击事件有系统级抖动抑制,表现没那么明显;OpenHarmony 分支对手势事件的去重策略略有不同,害得我多花了一天时间。
6. 性能实测与后续扩展建议
6.1 在OpenHarmony设备上如何测性能
适配完成后,我在 OpenHarmony 开发板上做了一轮性能验证。验证重点是:复选框中包含动画,渲染层是否会产生不必要的重绘;列表滑动时,动画 controller 是否还在持续跑。
Flutter 标准性能工具在 OpenHarmony 分支上不一定能用全,尤其是 DevTools 的连接,经常出现连不上或者只能看部分页面的情况。我的做法是:
- 在代码里用
Stopwatch记录关键操作的耗时,比如点击到回调、动画到首帧。 - 用
PerformanceOverlay打开渲染层调试,观察有无着色器编译卡顿。 - 起一个 100 行、每行一个复选框的页面,用
index模式滚动,看是否出现掉帧。
实测数据大概是这样:100 个复选框同时存在,主流测 60fps,正常滚动没有明显掉帧;切换状态时动画首帧开销约 2~4ms,没有触发 Build 暴击。在低端设备上,如果复选框页面数量极大(超过 500 个),建议开启RepaintBoundary,避免每个复选框的状态变化都重绘整个页面。
这里踩过一个具体的坑:一开始我没有给复选框加RepaintBoundary,结果在低端 OpenHarmony 设备上,滚动时只要有一个复选框状态改变,整屏内容都会重绘一次,帧率直接掉到 30fps 以下。加上RepaintBoundary之后,每个复选框独立重绘,帧率恢复稳定。这个优化成本极低,收益却非常大。
6.2 后续可复用的适配模型
复选框适配完了,整个 shadcn_ui 体系里还有 Switch、Radio、Slider、Input 等一大批组件。它们各自的逻辑不同,但适配流程完全可以复用:
- 先做依赖体检,确认组件是否只依赖 Flutter 标准库。
- 把组件放进最小 Demo,跑一遍基础渲染,排除平台崩溃风险。
- 逐项梳理状态:受控/非受控,禁用/错误/聚焦,全部列出成表。
- 用单一 AnimationController 驱动所有视觉变化,避免隐式动画和显式动画混用。
- 专门测试键盘、遥控器、读屏三种输入方式,而不是只测触控。
- 在低端设备上做性能验证,重点看列表滚动和批量状态变化。
按这套模型,每个组件大约一到两天就能跑通。相比一开始“全量移植、一次性跑所有组件”的激进思路,这个“小步快跑”的节奏更稳。尤其是遇到跨端差异时,问题范围小,定位快,不会把时间和精力浪费在“不知道谁先出错”的混沌状态里。
最后再分享一点个人体会:适配 OpenHarmony 和适配 Android 的最大区别,不是 API 多寡,而是你少了“可以在网上随便搜到现成答案”的安全感。很多问题没有现成文档,必须在真机上一步步试。所以从一开始就别想着“一步到位”,把调试链路打通,把日志打印养好,尽量把问题控制在最小范围内,慢慢啃,反而比到处找灵丹妙药更有效。复选框只是第一步,但这一步走踏实了,剩下的组件就都只是重复劳动,谈不上攻坚了。