ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

鸿蒙OS Next上Flutter软键盘感知适配:从插件补全到避让实践

鸿蒙OS Next上Flutter软键盘感知适配:从插件补全到避让实践 1. 为什么在鸿蒙OS Next上要给软键盘单独做一层“感知”我第一次把App从Android往鸿蒙OS Next迁移很快卡在一个非常普通的功能上聊天页底部输入框。Android上这个问题十年前就有相对成熟的解法——窗口自适应调整大小键盘弹出来页面自动上移。到了鸿蒙OS Next上你以为同样配置就好了结果键盘照常弹出输入框被盖得严严实实resizeToAvoidBottomInset像失灵了一样。更麻烦的是部分机型上键盘都快弹完了应用却压根不知道键盘已经出现因为平台的键盘事件根本没有形成Flutter能消费的媒体查询。这就是我要写这篇文章的原因在鸿蒙OS Next上软键盘感知必须当作一个独立的工程问题来对待。不是说Flutter不能处理键盘而是OpenHarmony的窗口系统与Android差异不小官方flutter_keyboard_visibility插件只带Android/iOS/Web等平台实现没有OpenHarmony实现。要想让应用在鸿蒙OS Next上获得一致的键盘体验就得自己给它补上ohos这一层原生适配。这篇文章会把完整的接入方案、ArkTS侧实现、Flutter侧避让以及踩坑记录都过一遍。适合正在做Flutter应用鸿蒙化迁移、或者准备在OpenHarmony上正式发布App的团队参考。有些人可能会问鸿蒙OS Next本身就是移动系统系统键盘弹起时输入框为什么不能像Android那样自动被推上去要理解这个问题得先把“键盘可见性”和“键盘避让”拆成两件事来看。1.1 键盘可见性与页面避让其实是两件事移动开发里的“键盘问题”通常包含两个层面。第一层是感知键盘弹起了没有键盘当前有多高第二层是避让界面如何腾出空间让输入框不被盖住。在Android上这两件事本质上都交给了系统。键盘弹出时窗口会resizeMediaQuery.viewInsets自动变化Scaffold配合resizeToAvoidBottomInset就能实现避让。感知反而用得更少——因为系统自动帮你处理了你只要在极端场景下比如自定义全屏弹窗、列表滚动到底部时才需要手动听键盘事件。到了iOS上键盘是浮在页面上的系统通过safe area和viewInsets通知应用底部布局默认会被推上去。感知同样被弱化因为系统的避让机制足够完善。鸿蒙OS Next则夹在中间。它的Flutter引擎适配还在快速迭代部分引擎版本里MediaQuery.viewInsets.bottom是0或者更新不及时导致Android上那套“自动避让”经验直接失效。这时候最稳妥的做法就是绕过框架对insets的依赖自己先拿到键盘状态和高度再手动驱动布局。你真正需要的是一个可靠的、跨平台一致的键盘事件源这正是flutter_keyboard_visibility这类插件的设计初衷。它的核心不是“帮你避让”而是“把键盘这个系统事件变成一个Flutter侧可以订阅的流”。至于拿到事件之后怎么避让你完全可以自己控制。1.2 标准pub.dev插件在鸿蒙OS Next上卡在了哪一步如果你直接在自己项目的pubspec里加flutter_keyboard_visibility: ^0.0.4然后跑一个鸿蒙OS Next真机大概率不会报编译错误但运行时会有两种情况。第一种情况调用KeyboardVisibility.isVisible()抛MissingPluginException因为Flutter引擎在注册插件时找不到对应的ohos实现类。第二种情况更隐蔽如果某些渠道包装了默认实现方法不会抛异常但事件流永远收不到原生侧的真值键盘可见性一直是初始值。问题出在插件的声明结构上。Flutter插件要支持某个平台必须在pubspec的flutter.plugin.platforms里为该平台声明对应的插件类路径。flutter_keyboard_visibility目前只声明了android、ios、web三个平台没有ohos节点Flutter for OpenHarmony的插件加载机制自然找不到原生实现。这里要说明的是Flutter for OpenHarmony本身是可用的OpenHarmony SIG维护的flutter_flutter分支已经支持在工程里创建ohos目录也能识别pubspec里新增的ohos平台节点。只是pub.dev上的众多插件还没有及时补齐这一层。所以真正的任务不是从零开发一个键盘插件而是把已有的flutter_keyboard_visibility在ohos平台上的缺口补上。2. 工程接入用dependency_overrides把插件引到OpenHarmony实现既然上游还没有ohos实现最直接的办法就是fork一个仓库把ohos平台代码补进去然后通过dependency_overrides让项目使用这份本地或者自建Git仓库的代码。这样业务侧Dart代码完全不用改我们只是在同样的API表面底下多了一层原生实现。2.1 先确认Flutter for OpenHarmony环境已就绪动手改插件之前先确认你的本机环境能正常开发OpenHarmony的Flutter应用。这里最基础的三件事第一Flutter SDK必须使用OpenHarmony分支。你可以用OpenHarmony SIG维护的flutter_flutter库也可以使用公司内部基于它定制的版本。flutter doctor能看到OpenHarmony工具链说明就说明基础环境没问题。第二DevEco Studio的版本、OpenHarmony SDK版本和Flutter引擎分支要匹配。我用的是DevEco Studio 5.0对应的SDK编译目标的API等级根据自己的真机系统来设置。版本不匹配时会遇到一些奇怪的编译报错比如找不到ohos.flutter_ohos模块这类问题大多出在Flutter引擎和DevEco版本对不上。第三建议用fvm管理Flutter SDK。鸿蒙OS Next的适配分支更新频率高团队多人协作时如果每个人本地的Flutter版本不一致问题排查会非常痛苦。fvm可以让整个团队锁定同一个SDK版本。如果你还没有为项目创建ohos目录可以在项目根目录执行类似flutter create --platformsandroid,ios,ohos .的命令来补齐。工程里能看到ohos目录后才说明Flutter工具链已经把这个项目识别为支持OpenHarmony的应用。2.2 pubspec最小配置结构要让项目使用我们自己补全了ohos实现的flutter_keyboard_visibility推荐用dependency_overrides。它是Dart pub提供的依赖覆盖机制会把某个包固定到你指定的本地路径或Git仓库优先级高于远程pub.dev上的版本。dependencies: flutter_keyboard_visibility: ^0.0.4 dependency_overrides: flutter_keyboard_visibility: path: ../flutter_keyboard_visibility_ohos本地插件的目录结构应当是完整的一个Flutter插件包而不只是ohos原生代码。因为我需要保留Android和iOS的原生实现只新增ohos目录这样才能在三个平台上保持同一套Dart API。flutter_keyboard_visibility_ohos/ ├── pubspec.yaml ├── lib/ │ └── flutter_keyboard_visibility.dart ├── android/ ├── ios/ ├── ohos/ │ ├── oh-package.json5 │ └── src/main/ets/ │ ├── FlutterKeyboardVisibilityPlugin.ets │ ├── KeyboardVisibilityManager.ets │ └── index.ets关键点在这里的pubspec.yaml。原插件没有声明ohos平台节点我需要手动加上flutter: plugin: platforms: android: package: it.caspam.open_hide_keyboard pluginClass: FlutterKeyboardVisibilityPlugin ios: pluginClass: FlutterKeyboardVisibilityPlugin ohos: pluginClass: FlutterKeyboardVisibilityPluginpluginClass的名字需要与ohos/src/main/ets下导出的主类名完全一致大小写也要对。Flutter for OpenHarmony在构建时会读取这里去ohos目录下找对应的ArkTS类并注册。2.3 验证插件是否真正被引擎加载代码路径写对了不代表引擎真的加载了。我一开始就遇到过代码看起来没问题、实际上是缓存没刷新的情况结果排查半天。实用的验证方法有两个。第一在插件onAttachToEngine里加日志用HiLog输出一条标记。如果真机上能看到这条日志说明插件注册成功。这个方法最直接也最快。第二用Flutter侧的channel调试工具或者干脆在Dart侧临时调用一下KeyboardVisibility.isVisible()。如果之前会抛MissingPluginException注册成功后返回值就正常了。需要注意的是改完插件代码后要重新编译整个工程有时候增量编译不会触发ohos目录下的重新构建。如果日志始终不出现排查顺序就两件事一是pubspec里ohos.pluginClass的类名和ArkTS文件导出的类名是否完全一致二是ohos/oh-package.json5里的包名与pubspec声明是否冲突。这两个问题我想当常见一般检查后都能解决。3. ArkTS侧实现从WindowManager到通道数据的完整链路整个适配的核心是ArkTS侧的原生代码。它要做三件事注册方法通道、注册事件通道、从系统窗口监听键盘高度变化并推送给Flutter侧。先理解一下flutter_keyboard_visibility在Dart侧的工作机制。它的KeyboardVisibility.isVisible()走的是方法通道用于主动查询当前状态。KeyboardVisibility.onChange走的是事件通道用于持续监听键盘可见性变化。两个通道的名称都是固定的不能随意改否则Dart侧会收不到数据。3.1 插件注册与通道契约在OpenHarmony的Flutter插件体系里每个插件需要实现Plugin接口并在onAttachToEngine里完成通道初始化。这里的代码结构如下import { MethodCall, MethodChannel, EventChannel, EventSink, Plugin, PluginContext, } from ohos/flutter_ohos; import { KeyboardVisibilityManager } from ./KeyboardVisibilityManager; export class FlutterKeyboardVisibilityPlugin implements Plugin { private context: PluginContext | undefined; private methodChannel: MethodChannel | undefined; private eventChannel: EventChannel | undefined; onAttachToEngine(context: PluginContext): void { this.context context; KeyboardVisibilityManager.init(context); this.methodChannel new MethodChannel( context, flutter_keyboard_visibility ); this.methodChannel.setMethodCallHandler((call: MethodCall) { return this.handleMethodCall(call); }); this.eventChannel new EventChannel( context, flutter_keyboard_visibility/usages ); this.eventChannel.setStreamHandler({ onListen: (arguments: Object | null, sink: EventSink) { KeyboardVisibilityManager.addListener(sink); }, onCancel: () { KeyboardVisibilityManager.removeAllListeners(); }, }); } private async handleMethodCall(call: MethodCall): PromiseObject | null { switch (call.method) { case isVisible: return KeyboardVisibilityManager.isKeyboardVisible; case register: KeyboardVisibilityManager.register(); return null; case unregister: KeyboardVisibilityManager.unregister(); return null; default: return null; } } onDetachFromEngine(): void { KeyboardVisibilityManager.dispose(); this.eventChannel?.setStreamHandler(null); this.context undefined; } }这里的方法通道和方法名要与Dart侧完全一致。register和unregister是Flutter侧在订阅和取消订阅事件流时调用的生命周期方法用于告诉原生侧“有没有人在听键盘事件”。EventChannel的onListen回调里我们把sink交给KeyboardVisibilityManager管理。未来所有键盘事件都通过这个sink推给Flutter侧。这里要特别留意EventSink是有生命周期的如果onCancel时没有清理已经取消订阅的sink还在接收事件轻则内存泄漏重则导致后续订阅者收不到数据。3.2 通过keyboardHeightChange拿到键盘状态OpenHarmony窗口系统提供keyboardHeightChange事件键盘弹出、收起、高度变化时都会回调。监听和初始状态读取都放在KeyboardVisibilityManager里。它的实现大概是这样import { window } from kit.ArkUI; import type { EventSink } from ohos/flutter_ohos; import type { PluginContext } from ohos/flutter_ohos; export class KeyboardVisibilityManager { private static win: window.Window | undefined; private static sinks: EventSink[] []; private static _isVisible false; private static _keyboardHeight 0; private static registerCount 0; static async init(context: PluginContext): Promisevoid { const win await window.getLastWindow(context); this.win win; win.on(keyboardHeightChange, (height: number) { this.handleHeightChange(height); }); this.handleHeightChange(win.getWindowProperties().keyboardHeight ?? 0); } private static handleHeightChange(height: number): void { this._keyboardHeight height; const visible height 0; if (visible ! this._isVisible) { this._isVisible visible; this.notify(visible); } } private static notify(visible: boolean): void { this.sinks.forEach((sink) { sink.success(visible); }); } static addListener(sink: EventSink): void { this.sinks.push(sink); sink.success(this._isVisible); } static removeAllListeners(): void { this.sinks []; } static register(): void { this.registerCount; } static unregister(): void { this.registerCount Math.max(0, this.registerCount - 1); } static dispose(): void { this.win?.off(keyboardHeightChange); this.win undefined; this.sinks []; } }三个细节必须说明。第一个是window.getLastWindow(context)的上下文类型。在部分ohos/flutter_ohos版本里PluginContext直接实现了Context接口可以直接传进去。如果你的版本类型不匹配就转成context.getApplicationContext()再传。不同版本的API细节确实有差异以你实际用的SDK为准。第二个是初始状态。插件注册时键盘可能已经弹出了而on(keyboardHeightChange)只会在键盘状态发生变化时触发也就是说已经处于“弹出”状态的键盘不会补发历史事件。所以init里必须主动读一次当前高度。第三个是可见性的判定规则。height 0代表键盘可见这个判断在绝大多数场景下是对的。但有些输入法在完全收起时会先发一个高度为0的事件再发一个很小的残影事件这种抖动问题后面第5节会专门讲。3.3 事件通道Stream设计与反注册事件通道看起来就是简单地把键盘状态推给Flutter侧但有几个细节值得展开。addListener里除了把sink加入数组还要立刻success(_isVisible)一次。这个设计跟Dart侧的BehaviorSubject行为保持一致。BehaviorSubject在订阅时会立即返回最近一次的值所以如果你不推初始值第一个订阅者可能会错过当前键盘状态一直等到下一次变化才收到数据。我最初漏掉这行代码结果每次冷启动进入页面后onChange流要等到键盘状态真正变化才会触发初始状态永远是false排查了很久才找到是这里的问题。dispose里调用win.off(keyboardHeightChange)时需要注意判空。如果init时获取窗口失败导致win为空直接调用off会抛异常。代码里用了可选链this.win?.off(...)来避免这个问题这算是个防御性写法。反注册的完整链路也要处理好。Flutter侧页面销毁时会触发onCancel此时调用removeAllListeners清空所有sink。但Dart侧底层可能同时维护着多个订阅者所以onCancel不能直接停掉整个事件通道它应该只清空本插件的sink列表。这里的做法是把sink列表直接置空同时保留方法通道的注册能力等待下一个订阅者重新进入。4. Flutter侧避让拿到键盘高度之后怎么组织布局原生侧工作完成后Dart侧就能用熟悉的API感知键盘。但感知只是第一步真正让界面“让位”还需要正确组织布局。这里我建议的策略是不要完全依赖resizeToAvoidBottomInset而是用拿到的事件手动驱动布局。4.1 使用KeyboardVisibilityBuilder做局部避让如果是整体页面都需要跟着键盘变化最省事的方式是插件自带的KeyboardVisibilityBuilder。它本质上就是在监听onChange然后回调时触发重建。import package:flutter/material.dart; import package:flutter_keyboard_visibility/flutter_keyboard_visibility.dart; class ChatInputPage extends StatelessWidget { const ChatInputPage({super.key}); override Widget build(BuildContext context) { return Scaffold( backgroundColor: Colors.white, body: KeyboardVisibilityBuilder( builder: (context, isKeyboardVisible) { return Column( children: [ const Expanded(child: MessageList()), _buildInputBar(isKeyboardVisible), ], ); }, ), ); } }布局层要做的事情是根据isKeyboardVisible决定输入栏底部是否加间距。键盘弹起时给输入栏底部补上键盘高度键盘收起时间距归零。这个逻辑抽象成一个方法Widget _buildInputBar(bool isKeyboardVisible) { final bottomPadding isKeyboardVisible ? KeyboardVisibility.logicalHeight() : MediaQuery.of(context).padding.bottom; return AnimatedPadding( duration: const Duration(milliseconds: 200), curve: Curves.easeOut, padding: EdgeInsets.only(bottom: bottomPadding), child: _inputField(), ); }KeyboardVisibility.logicalHeight()是原版插件在某些版本里提供的高度查询方法。如果你的fork没有实现高度查询只实现了可见性事件那么可以在拿到isKeyboardVisibletrue后用MediaQuery.of(context).viewInsets.bottom作为兜底。在我的实践里因为鸿蒙OS Next的viewInsets偶尔不可靠所以推荐优先使用插件侧直接读取的高度。两套方案可以同时保留哪边数据有效用哪边。4.2 resizeToAvoidBottomInset在鸿蒙OS Next上的边界这是最容易混淆的部分。resizeToAvoidBottomInset的作用是让Scaffold根据MediaQuery.viewInsets自动缩小body。它在Android上是自动工作的在OpenHarmony早期Flutter引擎里则表现不稳定。有的版本是viewInsets为0有的版本是延迟很大键盘都弹完了body才跳一下。我的稳妥方案是保持resizeToAvoidBottomInset: false由你手动控制底部内边距。使用AnimatedContainer或AnimatedPadding对底部间距做过渡动画避免键盘弹出瞬间跳变。把键盘高度的控制权全部收归KeyboardVisibilityBuilder避免“系统自动避让”和“手动避让”叠在一起产生双倍间距。这套做法的核心是让布局完全可控不依赖某一版引擎对insets的处理。付出的代价是代码量多一点但换来的是三个平台一致的行为。4.3 全屏模式下与底部导航的联动很多鸿蒙OS Next应用是全屏沉浸式的或者使用了底部Tab。键盘弹出时如果底部Tab还占着位置输入框就会被Tab和键盘双层夹击出现奇怪的间距。处理办法是把底栏本身也放进避让策略。键盘弹起时直接隐藏底部的Tab栏输入框紧贴键盘顶部Scaffold( backgroundColor: Colors.white, body: KeyboardVisibilityBuilder( builder: (context, visible) { return Column( children: [ Expanded(child: content), if (!visible) const BottomNavigationBar(...), inputBar, ], ); }, ), )很多主流App在竖屏输入时都会隐藏Tab栏因为输入过程中根本没有切换Tab的需求。在鸿蒙OS Next上这个策略尤其实用——它规避了系统底部安全区与键盘避让之间的复杂交互让布局状态只剩下“输入态”和“浏览态”两种逻辑简单清晰。5. 实测踩坑记录事件时序、单位换算与重复回调适配代码写完不是终点我在真机和模拟器上都踩过几个坑分享出来帮你少走弯路。5.1 事件通道首个事件丢失第一次接通后isVisible的静态方法查询正常但onChange流迟迟收不到第一帧数据要等键盘状态再变化一次才有反应。问题就出在3.3节提到的初始值推送。我最初在addListener里只把sink加入了数组没有主动success(_isVisible)。加上之后流订阅者立刻会收到当前状态行为与Android保持一致。排查这个问题时用过的方法也可以分享先用DevEco的HiLog看原生侧日志。如果sink.success执行了但Dart侧收不到问题多半出在事件通道的sink生命周期如果原生侧根本没执行那就是初始值推送逻辑缺失。一旦定位到问题层面修复通常很快。5.2 keyboardHeight的单位与Flutter逻辑像素keyboardHeightChange回调里的高度单位是vp虚拟像素而Flutter的MediaQuery里viewInsets单位是逻辑像素。在绝大多数设备上vp和Flutter逻辑像素是同一个缩放体系直接传过去没有偏差。但遇到特殊分辨率的折叠屏或者平板时还是要留个心眼。我发现过一个常见错误是开发者“好心”做了两次换算原生侧把vp转成pxDart侧又除以devicePixelRatio结果间距变成原来的一半。第一次实现时我也踩过这个坑后来定位到原因后直接在原生侧统一输出vpDart侧不做任何转换。这里的关键是先确认高度数据的单位统一是什么再决定是否换算。同一套数据不要在两端各自处理一次。5.3 回调风暴与防抖处理keyboardHeightChange在部分系统版本上会连续触发多次。快速切换输入法、中文拼音候选框弹出收起时高度回调能在很短的时间内连发四五次。如果每次都触发setState重建整个页面性能就会明显下降。我在工程里做了简单的防抖Timer? _debounce; void _onKeyboardChanged(bool visible) { _debounce?.cancel(); _debounce Timer(const Duration(milliseconds: 80), () { setState(() { _isKeyboardVisible visible; }); }); }80毫秒的防抖既能避免键盘状态快速抖动导致布局闪烁也能保留足够的实时性。真正的键盘动画通常超过这个时长用户不会明显感觉到延迟。不过防抖只针对可见性状态。如果后续要基于键盘高度做精细交互动画比如输入框跟随键盘平滑移动就不要防抖改成AnimationController驱动把高度变化直接映射到动画值上。6. 验证清单与后续维护建议适配完成不是终点我在交付出包前会按一个固定清单检查这里分享给你。6.1 真机环境下的功能验证步骤模拟器很难完全模拟输入法弹起时窗口的变化所以至少准备一台真机。我的验证点整理成了一张表验证项操作预期感知初始状态进入页面时键盘已弹出直接查isVisible返回true事件流首帧订阅onChange后立刻等待半秒内收到当前状态键盘弹出点击输入框收到true事件输入框上移键盘收起点击空白区域或系统返回收到false事件输入框回位快速切换输入法切换输入法类型不闪跳最终高度正确折叠屏/横屏转屏后重复上述步骤高度与坐标跟随新尺寸页面销毁从中途返回无leak日志keyboardHeightChange已反注册避让部分还要额外截图对比键盘弹起前后的页面布局。我的经验是感知功能合格的标准是Android和鸿蒙OS Next的Dart侧事件序列完全一致避让功能合格的标准是输入框始终紧贴键盘顶部且没有叠加空白。6.2 与Android/iOS实现的行为差异我在适配完成后特意用同一套Dart代码在三个平台上对比最终表现一致但内部机制确实有差异平台键盘事件来源事件单位避让机制AndroidOnGlobalLayoutListenerdpwindow自动resizeiOSUIKeyboardWillChangeFrameptsafe area自动调整OpenHarmonywindow keyboardHeightChangevp窗口避让模式手动布局这解释了一个重要现象为什么Dart侧代码可以完全相同因为插件已经帮你把平台差异吞掉了。这也是我坚持适配flutter_keyboard_visibility这一层而不是在业务代码里到处监听系统事件的理由。一次适配全局生效业务团队不需要理解每个平台的键盘API。6.3 版本升级时的适配要点最后说维护。OpenHarmony的Flutter引擎迭代很快ohos/flutter_ohos的API也在变化。每次升级SDK或者Flutter版本建议至少跑三件事第一重新跑一遍上面的验证清单重点看PluginContext相关调用是否出现编译告警。很多API在OpenHarmony新版本里改了签名但不会直接报错只在运行时出现异常。第二检查window.getLastWindow的调用方式有没有变化。窗口对象获取方式在不同API等级上有区别尤其从某个版本开始推荐从WindowStage获取主窗口我这边一直用的是兼容性更好的getLastWindow方式。第三留意Dart侧包版本。官方flutter_keyboard_visibility如果发布了新API对比一下我们的ohos实现是否缺失。如果上游原生增加了一个新方法而ohos侧没有实现Dart侧调用时会走默认值这个默认值不一定符合预期。我自己的做法是把这套适配放在公司内部的统一组件库里由组件负责人统一回归。业务团队不感知底层变化只需要升级组件库版本。这样既保证了三个平台行为一致也让键盘适配这类底层能力成为公共资产。这次完整适配下来我最大的体会是在鸿蒙OS Next的Flutter生态里很多底层能力的“最后一公里”还需要开发者自己动手。键盘事件只是其中之一但它影响面极广几乎每个带输入框的页面都会涉及。与其在业务代码里到处规避不如一次性补全插件层把跨端一致性收口在一个只有几百行代码的原生实现里。另外一个小建议把适配patch单独保留在Git仓库的一个独立分支命名时对应上游插件版本号。将来上游如果补上了官方ohos支持你可以随时切换回来这套适配经验也不会浪费。这是我在多次版本混乱的教训之后总结出来的处理方式。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进