1. 为什么OpenHarmony上的表单不能照搬Android经验
先把结论放在前面:表单输入与验证这套东西,在Android上你闭着眼睛写都不会出大问题,但换到Flutter for OpenHarmony,同样的代码,表现完全可能是两回事。原因不是Flutter框架本身有什么缺陷,而是OpenHarmony在输入法服务、软键盘避让、文本通道这几个底层环节上,和Android有本质差异。
我做某个跨平台系统迁移的时候,第一步就是把原来跑得好好的表单页直接编译到OpenHarmony设备上,结果第一轮就翻车了:输入框点上去软键盘不弹、弹了之后又把提交按钮顶出屏幕、中文输入法下输入一串拼音直接被inputFormatter拦掉一半。这些问题在Android和iOS上都从未出现过,所以当时第一反应是“Flutter的OpenHarmony支持还太早期”,但排查了一圈才发现,锅真不全在Flutter侧。
1.1 输入法服务的路由方式完全不同
Android的输入法框架是IME(Input Method Editor),它通过InputMethodManager和输入框建立连接,Flutter引擎在这里扮演的是“中转站”的角色。OpenHarmony走的是另一套体系,它把输入法认证、输入会话管理、软键盘显示都抽象成了独立的输入法服务(Input Method Service),而且这套服务和Flutter的TextInputChannel对接时,并不是所有消息都会被消费。
实际表现上,最明显的就是TextInputClient的某些回调没有被完整转发。比如在Android上,输入法在删除拼音串或者候选词上屏时,会通过deleteSurroundingText和setComposingRegion通知Flutter引擎;但在OpenHarmony上,这两个回调有时会丢失,尤其是快速删除或连续上屏的时候,Flutter侧拿到的TextEditingValue已经和输入法内部状态对不上了。
这就导致一个很麻烦的副作用:你写的LengthLimitingTextInputFormatter或自定义正则formatter,在某些情况下会把输入法正在组合中的拼音串当成最终文本来处理,然后直接给你截掉或者替换掉。用户在拼音输到一半的时候,文本突然“跳动”甚至清空,体验非常糟糕。
1.2 软键盘避让是两套完全不同的机制
Android的软键盘避让走的是adjustResize或者adjustPan,Flutter引擎拿到windowSoftInputMode之后,会通过Scaffold的resizeToAvoidBottomInset自动把底部组件顶上去。这套机制在Android上已经很成熟,基本不用操心。
OpenHarmony的键盘弹出不是通过“窗口尺寸变化”来通知应用的,而是通过安全区域(safe area)的概念,把键盘区域定义成系统的“避让区域”。Flutter的MediaQuery在这套体系下,viewInsets并不总是能正确反映键盘高度。我在某次调试里发现,键盘弹起之后,MediaQuery.of(context).viewInsets.bottom返回的值竟然是0,导致Scaffold认为没有键盘弹出,所有底部内容原地不动,直接被键盘盖住。
这个问题的解法不是“换个参数”,而是要从窗口系统的接口去拿真实的键盘高度,不能依赖Flutter已经帮你算好的那套数据。后面我会在实战环节给出一个可用的监听方案。
1.3 Flutter引擎层只“翻译”了一部分能力
Flutter的OpenHarmony版本,是基于Flutter引擎的某个稳定分支做适配的。适配过程中,底层C++层负责把Dart侧的TextInputChannel消息转发给OpenHarmony的输入法服务,但并不是所有消息都做了完整映射。
我自己碰到过的一个例子:TextInputAction.done在Android上会触发输入法的“完成”按钮,但在OpenHarmony上,这个映射被做成了“无动作”的默认处理,导致用户点击键盘右下角的完成按钮,事件完全没有下沉到Flutter侧,onSubmitted一直不触发。用户以为填完就提交了,实际上什么都没发生。
所以,在做表单输入与验证之前,先得接受一个现实:这个平台上“键位动作”“组合文本”“软键盘高度”等行为,不能用Android的心智模型去推断,必须亲自验证一遍,才能确定哪些Flutter能力是可用的、哪些需要自己补一层兼容逻辑。
2. 表单数据采集的工程改造与输入约束
聊完底层差异,接下来进入工程层面。我之前做的是一个给OpenHarmony设备用的数据录入模块,核心场景是用户在一个长表单里填写基础信息,包含文本输入、数字输入、日期选择、下拉选项等,最后统一提交到后端。对数据质量的要求比较直接:不能出现超长脏数据、不能出现格式非法的字段、不能因为键盘遮挡导致用户看不到正在编辑的行。
2.1 运行环境与版本组合
先说环境,方便你对照。
- 设备端:64位ARM架构的OpenHarmony开发板,系统版本为4.x分支
- Flutter SDK:采用适配OpenHarmony的fl_oh引擎分支
- Dart SDK:3.x
- 目标架构:armeabi-v7a和arm64-v8a同时打包验证
这里有个经典坑:如果你同时要做Android和OpenHarmony两个目标,项目里会同时存在两套原生工程。Android分支下大家习以为常的那些插件,很多在OpenHarmony上根本没有对应实现,编译期就会失败,就算编译过了,跑起来也会在原生层静默崩溃。
2.2 依赖库的替换思路
在表单场景里,最常用到的第三方库无非是“选择器”“键盘类型控制”“日期处理”这几类。我当时的做法是,把带原生代码的插件全部替换成纯Dart实现,或者替换成OpenHarmony适配包。
举个例子:日期选择器,Android上很多人用showDatePicker,它走的是Flutter框架内部的Material组件,不涉及平台插件,所以可以直接用。但如果你用了某个第三方的日期库,它内部可能调用了platform_channel去拿系统时区或地区设置,那在OpenHarmony上就得检查这个Channel是否被实现。
我踩过的坑是某个基于path_provider的缓存目录工具,它在OpenHarmony上没有原生实现,导致表单里需要上传附件的功能直接不可用。解决方案是临时改成让用户手动指定一个可写的本地路径,虽然丑了点,但保证了表单主流程不被阻断。
在你自己的项目里,我建议先做一个插件依赖清单,把每个依赖标注成三类:纯Dart实现、有OpenHarmony适配、只支持Android/iOS。表单相关的核心链路尽量只使用第一类,把风险控制在最低。
2.3 表单页面的基础结构
这一小节给出一个可以直接套用的表单骨架代码,里面包含了输入控件的核心要素:控制器、焦点节点、格式化器、校验器。
class CollectFormPage extends StatefulWidget { const CollectFormPage({super.key}); @override State<CollectFormPage> createState() => _CollectFormPageState(); } class _CollectFormPageState extends State<CollectFormPage> { final _formKey = GlobalKey<FormState>(); late final TextEditingController _nameController; late final TextEditingController _phoneController; late final TextEditingController _amountController; late final FocusNode _nameFocus; late final FocusNode _phoneFocus; late final FocusNode _amountFocus; @override void initState() { super.initState(); _nameController = TextEditingController(); _phoneController = TextEditingController(); _amountController = TextEditingController(); _nameFocus = FocusNode(); _phoneFocus = FocusNode(); _amountFocus = FocusNode(); } @override void dispose() { _nameController.dispose(); _phoneController.dispose(); _amountController.dispose(); _nameFocus.dispose(); _phoneFocus.dispose(); _amountFocus.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text('数据采集表单')), resizeToAvoidBottomInset: true, body: SafeArea( child: SingleChildScrollView( padding: const EdgeInsets.all(16), child: Form( key: _formKey, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ TextFormField( controller: _nameController, focusNode: _nameFocus, decoration: const InputDecoration( labelText: '姓名', border: OutlineInputBorder(), ), inputFormatters: [ _NameInputFormatter(), ], validator: (value) { final text = value?.trim() ?? ''; if (text.isEmpty) { return '请输入姓名'; } if (text.length < 2) { return '姓名至少2个字符'; } return null; }, ), const SizedBox(height: 16), TextFormField( controller: _phoneController, focusNode: _phoneFocus, decoration: const InputDecoration( labelText: '手机号', border: OutlineInputBorder(), ), keyboardType: TextInputType.phone, inputFormatters: [ _PhoneInputFormatter(), ], validator: (value) { final text = value?.trim() ?? ''; if (text.isEmpty) { return '请输入手机号'; } if (!RegExp(r'^1\d{10}$').hasMatch(text)) { return '手机号格式不正确'; } return null; }, ), const SizedBox(height: 16), TextFormField( controller: _amountController, focusNode: _amountFocus, decoration: const InputDecoration( labelText: '金额', border: OutlineInputBorder(), ), keyboardType: const TextInputType.numberWithOptions( decimal: true, ), inputFormatters: [ _AmountInputFormatter(decimalDigits: 2), ], validator: (value) { final text = value?.trim() ?? ''; if (text.isEmpty) { return '请输入金额'; } final parsed = double.tryParse(text); if (parsed == null || parsed <= 0) { return '金额必须大于0'; } return null; }, ), const SizedBox(height: 24), SizedBox( width: double.infinity, height: 48, child: FilledButton( onPressed: _submit, child: const Text('提交'), ), ), ], ), ), ), ), ); } void _submit() { FocusManager.instance.primaryFocus?.unfocus(); if (!_formKey.currentState!.validate()) { return; } // 走保存逻辑 } }这段代码看着不复杂,但它已经是经过实战调整过的版本。有几个细节值得强调。
第一,FocusManager.instance.primaryFocus?.unfocus()在提交前一定要调,否则在OpenHarmony上键盘可能不会自动收起。Android上输入框失焦是自动的,但OpenHarmony某些输入法服务对失焦事件处理有延迟,你不主动收,键盘就一直在那。
第二,每个输入框都单独持有自己的FocusNode,这不是写代码的洁癖,而是为了后面做“下一项”跳转、以及键盘避让计算时能够精确定位当前操作的是哪个输入框。
第三,resizeToAvoidBottomInset: true只是第一道防线,前面我说过OpenHarmony会返回错误的viewInsets,所以真正的问题是这道防线有时形同虚设,后面会有专门的处理。
3. 输入校验的三个层次:格式化、合法性、业务约束
表单校验如果只靠validator一个手段,那一定是不完整的。我通常把它拆成三个层次,每一层职责不同,组合起来才能构建出可靠的数据采集链路。
3.1 层次一:inputFormatters,从源头拦截脏数据
inputFormatters发生在文本变化的时候,它直接作用于TextEditingValue,可以在数据进入控制器之前做处理。这一层的特点是“无声”的,用户感知不到被拦截了,只觉得“输不进去”。
我自己写了不少自定义formatter,下面是几个常用的例子。
姓名输入格式化
只允许中文、英文和空格,禁止输入数字和特殊符号:
class _NameInputFormatter extends TextInputFormatter { static final _pattern = RegExp(r'^[a-zA-Z\u4e00-\u9fa5\s]*$'); @override TextEditingValue formatEditUpdate( TextEditingValue oldValue, TextEditingValue newValue, ) { if (_pattern.hasMatch(newValue.text)) { return newValue; } return oldValue; } }手机号输入格式化
自动去除所有非数字字符,只保留下前11位:
class _PhoneInputFormatter extends TextInputFormatter { @override TextEditingValue formatEditUpdate( TextEditingValue oldValue, TextEditingValue newValue, ) { final digits = newValue.text.replaceAll(RegExp(r'\D'), ''); if (digits.length > 11) { return oldValue; } return newValue.copyWith( text: digits, selection: TextSelection.collapsed(offset: digits.length), ); } }金额输入格式化
限制小数位数的同时,避免出现1.这种情况被直接截断,这里要额外判断一下:
class _AmountInputFormatter extends TextInputFormatter { _AmountInputFormatter({required this.decimalDigits}); final int decimalDigits; @override TextEditingValue formatEditUpdate( TextEditingValue oldValue, TextEditingValue newValue, ) { final text = newValue.text; if (text.isEmpty) { return newValue; } final pattern = RegExp(r'^\d*\.?\d{0,' + decimalDigits.toString() + r'}$'); if (pattern.hasMatch(text)) { return newValue; } return oldValue; } }写formatter有一个核心原则:它只能做“基于字符规则”的过滤,不能做“依赖上下文状态”的判断。例如无法通过formatter判断这个金额是否大于0,因为formatter每次只处理当前输入片段,没有全局视角。凡是需要跨字段、跨业务规则的检查,都要放到下一层。
3.2 层次二:validator,在提交时给出明确提示
validator是用户感知最强的校验层,它处理的是“字段是不是合法”的问题。在这一层,我建议做一次trim处理,再去做内容判断。用户输入前后空格的情况很常见,尤其移动端表单,你永远不知道用户从哪里复制的文本。
另外一个需要关注的细节是autovalidateMode的选择。Flutter的AutovalidateMode默认是disabled,也就是只有调用validate()时才会触发校验。但实际体验中,把模式设成onUserInteraction更合理:用户在输入框之间跳转时,已经修改过的字段自动重新校验,提示更及时。这里要注意的是,OpenHarmony上输入框失焦事件有时会延迟,onUserInteraction触发的时机也可能变慢,所以如果发现“输入完了但是校验提示迟迟不出来”,不要慌,这是平台行为。
3.3 层次三:业务校验,在保存前做最终兜底
前两层都过了,不代表可以安心提交。在_submit()里,还需要做一次统一的业务校验。我通常在这里处理三类事情。
第一,字符串的再次清理。把每个字段的文本做trim(),去除首尾空白,避免脏数据进入后端。
第二,类型转换的兜底。比如金额字段,double.tryParse在Android上对"1."会解析成1.0,但在OpenHarmony上某些版本的Dart运行时可能返回null,所以提交前要么显示报错,要么统一格式化成两位小数再保存。
第三,重复提交的防抖。表单一秒内被连续点击多次保存,是移动端很常见的问题。我用一个简单的防抖逻辑来兜底:
bool _isSubmitting = false; Future<void> _submit() async { if (_isSubmitting) return; FocusManager.instance.primaryFocus?.unfocus(); if (!_formKey.currentState!.validate()) return; setState(() => _isSubmitting = true); try { await _saveToRemote(); } finally { setState(() => _isSubmitting = false); } }这个防抖除了防重复提交,还有一个额外作用:在_isSubmitting期间,用户切后台、锁屏、再来一个电话,都不会导致数据重复写入。实际业务里这是很容易被忽视的点。
4. 实战踩坑:从现象到根因的四类疑难杂症
迁移过程中最耗时间的不是写代码,而是排掉那些“理论上不该出现”的问题。下面四个问题是我在OpenHarmony上做表单输入时真实遇到过、并且最终找到根因的,每个都给出了排查链路和解决方案。
4.1 autofocus不生效与路由动画的关系
现象:一个只有单个输入框的搜索页,进入页面后输入框固定不会自动获得焦点,键盘也不弹出来。在Android上同样的代码一切正常。
排查过程:先确认Flutter侧没有报错,再单独抽出一个空白页面,只放一个TextFormField,设置autofocus: true,仍然不生效。这说明不是业务代码影响了焦点。接着把页面放进一个没有路由动画的容器里测试,焦点正常了。
根因:OpenHarmony的Flutter适配里,路由转场动画过程中焦点管理器的挂载时机不稳定。当页面带着位移/透明度动画进入时,引擎在动画未结束前不会把焦点事件下发到输入法服务,导致autofocus被吞掉。
解决方案:不依赖autofocus,在initState里用延迟去请求焦点:
Future<void> _requestFocusAfterRoute() async { await Future.delayed(const Duration(milliseconds: 400)); if (mounted) { _searchFocus.requestFocus(); } }400毫秒是经验值,可以覆盖大多数路由动画的时长。如果动画被系统缩短或禁用,这个延迟也不会产生明显副作用,只是晚了一眨眼的工夫。
4.2 中文输入法下formatter误伤拼音串
现象:在输入姓名时,中文拼音输入到一半,输入框里的拼音串会突然被截断,候选词上屏错误。
排查过程:单独把_NameInputFormatter从输入框上摘掉,问题消失。重新加回来,问题复现。于是可以确定,问题出在formatter对“组合中文本”的处理上。进一步在formatter里打印newValue.text,发现输入法在发送拼音组合文本时,newValue是拼音字符串,比如“zhangsan”,formatter按规则判断“这些拼音都是合法英文字母”,放行了;但当用户选择候选词“张三”上屏时,newValue变成了“张三”,formatter判断“这是中文,合法”,也放行了。问题出在删除键:当用户想删除拼音串的某一个字母时,输入法会先发送一个setComposingRegion,把光标定位在某个字母旁边,但OpenHarmony上没有把这个组合区域的状态同步给Flutter,formatter拿到的newValue是“被删除后的文本”,正则一看,仍然合法,但输入法内部的组合状态已经被破坏了,候选词全部丢失。
根因:OpenHarmony的输入法服务对“组合区域”的通知不完整,Flutter侧无法知道哪些文本是临时的拼音组合,哪些是确认后的最终文本。formatter在设计时没有考虑组合状态,就会误操作组合中的拼音。
解决方案:在formatter里,先检测newValue是否处于组合状态。TextEditingValue里虽然有composing字段,但OpenHarmony上它经常是空的。所以我换了一个更实用的策略:在formatEditUpdate里,如果旧值和新值之间存在“拼音替换中文”的情况,比如旧值长度较短,新值长度一下子变长了3到4个字符,就直接放行,不做过滤:
@override TextEditingValue formatEditUpdate( TextEditingValue oldValue, TextEditingValue newValue, ) { final oldLen = oldValue.text.length; final newLen = newValue.text.length; // 如果出现大跨度变化,大概率是候选词上屏,放行 if ((newLen - oldLen).abs() > 1) { return newValue; } if (_pattern.hasMatch(newValue.text)) { return newValue; } return oldValue; }这个方案有失精度,但换来了可用性。在OpenHarmony没有修正组合状态同步之前,这是性价比最高的做法。
4.3 软键盘弹出导致提交按钮上浮
现象:长表单页面里,点击靠下方的输入框,键盘弹出后整个Scaffold被压缩,提交按钮直接顶到键盘上方,遮住了部分正常内容。
排查过程:先检查Scaffold的resizeToAvoidBottomInset是否为true,确认是true但问题依然存在。接着在键盘弹出时打印MediaQuery.of(context).viewInsets.bottom,发现返回值是0。
根因:前面说过的,OpenHarmony的键盘避让走的是安全区方案,Flutter没有把它映射到viewInsets上。
解决方案:通过WindowInsets监听键盘高度,手动算出一个bottomPadding,把它加在ListView的底部:
import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; class KeyboardAwareList extends StatelessWidget { const KeyboardAwareList({super.key, required this.builder}); final Widget Function(double keyboardHeight) builder; @override Widget build(BuildContext context) { return AnimatedBuilder( animation: _KeyboardHeightController.instance, builder: (context, child) { final height = _KeyboardHeightController.instance.height; return builder(height); }, ); } } class _KeyboardHeightController extends ChangeNotifier { _KeyboardHeightController._() { _init(); } static final instance = _KeyboardHeightController._(); double _height = 0; double get height => _height; Future<void> _init() async { // 通过平台的WindowInsets获取键盘高度 final insets = View.of(WidgetsBinding.instance.rootElement).viewInsets; // 根据实际平台能力,这里因设备而异 } }在OpenHarmony具体的版本上,获取键盘高度的Channel可能和Android不同,需要对照你使用的适配引擎的接口文档做调整。核心思想是:依赖平台提供的安全区/可用区域数值,而不是Flutter的viewInsets,然后把高度注入页面做布局。
4.4 TextInputAction.done回调不触发
现象:最后一个输入框的textInputAction设为TextInputAction.done,用户点击键盘右下角的“完成”按钮,onSubmitted不触发,点击事件像是被吞掉了。
排查过程:先看onEditingComplete是否触发,结果也不触发。然后尝试在输入框外层包一个GestureDetector,把焦点手动转移,这时候键盘能收起,但按钮的“完成”事件本身仍然没有回调。
根因:OpenHarmony的输入法服务没有把ACTION_DONE这个按键动作映射到Flutter的TextInputAction.done上。它更习惯的是ACTION_SEND或ACTION_NEXT这类带明确语义的动作键,而“完成”的语义在它看来和“无动作”等价,所以直接忽略了。
解决方案:简单粗暴的做法是,把最后一个输入框的textInputAction改成TextInputAction.send,然后监听onSubmitted,在回调里执行提交逻辑。效果上用户看到的按钮文字是“发送”不是“完成”,但功能可用。如果你想保留“完成”字样,就得自定义输入法动作,这超出了Flutter层的能力范围,需要改原生工程,成本较高。
5. 验证策略:怎么证明“数据采集是可靠的”
代码写完了,坑也踩完了,最后多花一点篇幅聊验证。表单模块的正确性,必须通过数据和交互两个维度去验证:数据维度是指“存进去的东西是对的”,交互维度是指“用户操作是顺畅的”。
5.1 Widget测试里的输入模拟
Widget测试可以覆盖大部分校验逻辑。用tester.enterText模拟输入,然后调用提交按钮,断言校验提示是否出现、数据是否正确保存。
testWidgets('金额输入超过两位小数时拦截', (tester) async { await tester.pumpWidget(const CollectFormPage()); await tester.enterText( find.byType(TextFormField).at(2), '1234.567', ); await tester.tap(find.text('提交')); await tester.pump(); expect(find.text('金额必须大于0'), findsNothing); expect(find.text('1234.567'), findsOneWidget); });这个测试在OpenHarmony的引擎上跑的时候,有一个需要注意的点:tester.enterText在模拟输入时会把文本直接注入控制器,不会经过真实的输入法链路,所以组合文本那个坑测试不出来。但它能验证formatter的字符规则和validator的业务逻辑,价值仍然很大。
5.2 真机上的输入法矩阵测试
真正决定表单可靠性的,是下面这张真机测试矩阵。每一项在我自己的验证中都是必跑的:
| 检查项 | 操作步骤 | 通过标准 |
|---|---|---|
| 软键盘弹出 | 点击所有输入框 | 键盘弹出且不遮挡当前输入框 |
| 中文输入 | 打开系统默认中文输入法,输入姓名 | 拼音组合和候选词选择不丢字 |
| 数字输入 | 切换到数字键盘,输入金额 | 不能出现非数字字符 |
| 提交防抖 | 连续点击提交按钮5次 | 只发起一次保存请求 |
| 失焦行为 | 提交前先点击空白区域 | 键盘收起后再执行提交 |
| 超长文本 | 粘贴1000个字符到姓名框 | 输入被拦截,数据入库长度受限 |
| 特殊字符 | 输入手机号时粘贴字母和符号 | 字母符号被剔除 |
这张表里,有两项是Android上不用测的,但在OpenHarmony上必须测:中文输入和失焦行为。它们恰恰是前面踩坑总结里最耗费时间解决的问题。
5.3 数据入库一致性校验清单
交互层过了之后,还有最后一关:入库数据的一致性。我会在保存逻辑里,把待提交数据序列化成Json,做一次“预打印”,拿它去和数据库里实际存储的记录做对比,确保没有字段漂移。
一个很实用的小技巧是,在调试模式下,为每次提交生成一个requestId,把请求参数和返回结果都打出来,一旦数据不一致,可以通过requestId快速定位到具体的一次提交,不至于在大量日志里大海捞针。这个习惯在Android上就有,放到OpenHarmony上同样有效,而且因为OpenHarmony的调试工具相对没那么花哨,日志反而是最可靠的排错依据。
表单输入与验证这块内容,网上讨论不多,因为大环境下绝大多数人还在Android/iOS上打转。但我个人的体会是,越早把这些差异摸清楚,后面的坑就越少。OpenHarmony的Flutter适配还在快速更新,你现在踩过的每一个细节,未来都会变成别人查不到、只能自己趟的经验。表单是几乎所有业务应用的入口,谁先把“数据采集”这关打通,谁就能在生态早期占住位置。