先把结论放在前面:bones_ui 并不是一个悬空概念,它是一套能让 Flutter 项目快速长出 Web 后台气质的组件库。我当时把内部管理工具从 Android 端迁往鸿蒙平板时,第一时间想到的就是它。那次迁移让我意识到,Flutter 三方库的鸿蒙化,真正的成本不在编译,而在平台差异的理解和交互细节的取舍。这篇就围绕 bones_ui 的鸿蒙化过程,把环境搭建、组件适配、原生桥接、交互修正和性能调优一次讲透。适合想把 Flutter 项目搬到鸿蒙设备、或者打算在鸿蒙上引入 Web 风格 UI 的开发者参考。
1. bones_ui 是什么,以及为什么值得把它搬到鸿蒙
1.1 一套“长在 Flutter 里的 Web 风格组件库”
bones_ui 这个名字挺有意思,它想表达的是“骨架”——不是那种干巴巴的基础控件集合,而是把 Web 后台开发里最常见的组件形态,用 Flutter 的方式重新实现了一遍。响应式栅格、折叠导航、数据表格、筛选面板、卡片式统计块、模态对话框,这些都是 Web 风格管理端的高频元素。在实际项目里,产品经理拿一张 Web 原型图过来说“照着这个感觉做”,如果从零写,光是把栅格系统和导航抽屉折腾明白就得一到两天。bones_ui 的价值就在于把这些东西提前封装好,让 Flutter 开发者直接组装。
我选择它也有一部分原因在于它的设计取向:它默认的视觉语言是“直观”和“响应式”。直观指的是组件状态清晰——按钮的悬停、按压、禁用都有明确反馈;响应式指的是栅格能根据屏幕宽度自动调整列数和间距,而不是靠一个 MediaQuery 到处打补丁。这些特性在后来的鸿蒙适配里帮了大忙,因为鸿蒙设备从手机到平板再到 PC 的屏幕跨度非常大,一套固定宽度的界面是活不下去的。
1.2 多端统一的价值点:一套代码跑手机、平板和 PC
鸿蒙目前的设备形态差异比 Android 时代更夸张。手机是竖屏为主,平板是横屏为主,PC 则是鼠标键盘加高分辨率大屏。如果每个形态都维护一套 UI,成本会成倍上涨。Flutter 的核心优势就是“一套 Dart 代码,多个平台渲染”,bones_ui 的响应式能力正好补上了 UI 层面的空缺。
我在实际项目里跑通的效果是这样:同一个页面,在手机竖屏下栅格自动收成单列,导航抽屉变成汉堡菜单;到了平板和 PC 端,栅格自动展开成三列或者四列,导航变成固定的侧边栏。组件本身没有写任何平台判断逻辑,全靠 bones_ui 内部根据 MediaQuery 和窗口尺寸计算断点。这个体验在鸿蒙上尤为重要,因为鸿蒙的多端战略意味着同一个应用可能被安装到完全不同的设备上。
1.3 适配前需要先认清的现实
不过,把 bones_ui 搬到鸿蒙并不是“加一行依赖就能跑”的事。我总结了三个判断维度,准备移植任何 Flutter 三方库时都可以先套一下:
- 纯 Dart 实现,不依赖 dart:io、dart:ffi 或原生插件的组件,理论上直接可用,最多处理资源路径问题。
- 依赖 Flutter SDK 平台通道(MethodChannel、EventChannel、PlatformView)的组件,必须确认鸿蒙侧是否实现了对应的 ArkTS 原生代码。
- 依赖 C++ 动态库或者 .so 文件的组件,需要先验证鸿蒙的 NDK 编译链是否兼容,这一步通常最折腾。
bones_ui 大部分组件属于第一类,少数能力,比如内置 WebView、文件选择、剪贴板读取,属于第二类。这也是为什么后来我去研究 EventChannel 和 PlatformView 的鸿蒙化——不是炫技,而是这些能力绕不开。
2. 鸿蒙端 Flutter 环境搭建:工具链与工程改造的完整记录
2.1 我用的工具链版本组合
鸿蒙上跑 Flutter,不能用官方稳定版的 Flutter SDK,因为官方还没有把 ohos 作为一等平台支持。目前能走通的路是使用 OpenHarmony SIG 维护的 flutter_flutter 分支,配合 DevEco Studio 做原生壳子的编译和打包。
我当时的版本组合大概是这样,仅供参考,后续版本迭代后构建方式可能会有变化:
| 工具 | 版本/说明 |
|---|---|
| Flutter SDK | OpenHarmony-SIG 的 flutter_flutter 分支,基于 Flutter 3.x 定制 |
| DevEco Studio | 4.x 版本,主要用于打开 ohos 工程并编译 HAP |
| 鸿蒙 SDK | API 9 及以上,我实测用 API 10 的 Preview 版本兼容性更好 |
| HDC 工具 | 鸿蒙设备调试器,用于安装和日志查看 |
为什么不能用官方 Flutter?因为官方 SDK 在flutter create时不会生成 ohos 平台目录,构建脚本也没有集成 hvigor 的逻辑。鸿蒙分支做的事情就是在引擎层接入鸿蒙的图形栈和事件分发,同时把构建流程和 DevEco Studio 的打包链路打通。没有这一层,就算代码编译过了也上不了真机。
2.2 一个既有 Flutter 工程如何生成鸿蒙壳子
如果你手里已经有一个跑在 Android/iOS 上的 Flutter 项目,想把它移植到鸿蒙,不需要复制整个工程重写。我的做法是保证 lib 目录和 pubspec.yaml 不变,只重新生成一个带 ohos 平台的壳工程,再把业务代码复制进去。具体步骤:
- 安装好 OpenHarmony SIG 的 flutter_flutter 分支后,先执行
flutter doctor,确认能识别出 ohos 平台。 - 新建一个临时目录,执行
flutter create --platforms ohos --org com.example my_app,生成带有 ohos 目录的工程骨架。 - 把原项目的 lib 目录、pubspec.yaml、web 目录(如果有)整体复制到新工程里。
- 用 DevEco Studio 打开新工程根目录下的 ohos 文件夹,等待 Gradle/hvigor 同步完成。
- 连接鸿蒙真机或模拟器,执行构建并安装。
这套流程里有几个容易出错的地方。第一,flutter create生成工程的 Flutter SDK 路径必须和后续用于构建的 SDK 是同一个,否则会出现引擎版本不匹配的诡异报错。第二,复制 pubspec.yaml 后依赖需要重新flutter pub get,这个命令也必须用鸿蒙分支的 Flutter 执行。第三,ohos 目录下有几个自动生成的配置文件,比如entry/src/main/module.json5,尽量不要手工删改,除非你清楚自己在干嘛。
2.3 pubspec 和构建配置:引入 bones_ui 前的几处改动
引入 bones_ui 之前,我先把构建配置处理干净了。ohos 工程里有几个关键文件,它们的职责和 Android 工程不太一样:
build-profile.json5:相当于 Android 的 build.gradle,里面配置签名、模块依赖和设备类型。entry/src/main/module.json5:相当于 AndroidManifest.xml,声明权限、页面入口和后台任务类型。oh-package.json5:鸿蒙侧的包管理文件,用来声明对@ohos/flutter_ohos这类原生插件的依赖。
我遇到的一个典型问题是权限声明。bones_ui 内置的剪贴板读取和文件下载能力,在鸿蒙上分别对应ohos.permission.READ_MEDIA和ohos.permission.INTERNET,如果测试时发现功能静默失败,第一反应应该是去 module.json5 里查权限。
2.4 第一台真机跑通的最小验证
我的习惯是第一次真机跑通时,永远先跑一个最小集:新建工程里只留一个 Text 控件,显示“hello ohos”。确认 Flutter 引擎能在鸿蒙上正常起播,再引入 bones_ui 的单个组件做冒烟测试。直接全量引入很容易出现“装了 30 个依赖不知道哪个炸了”的情况,排错成本很高。
第一次跑通时经常遇到 Flutter 引擎黑屏或者加载不出来,这种基本是 HAP 打包时没有把 Flutter so 库打进去,或者FlutterLoader初始化时机不对。可以先在 DevEco 里查一下工程的 libs 目录下有没有libflutter.so,没有的话就检查 oh-package.json5 里@ohos/flutter_ohos的版本。
这一阶段不用着急调 UI,先把“Flutter 代码能在鸿蒙真机跑起来”这件事坐实。真机跑通之后,bones_ui 的适配才能进入真正的主题。
3. 首轮编译排雷:组件渲染、字体资源和 Impeller 的坑
3.1 纯 Dart 组件大部分直接可用,但有几个例外
bones_ui 的主体组件,比如栅格、按钮、面板、导航菜单,都是纯 Dart 绘制的,不依赖任何原生视图。这意味着它们的编译逻辑在鸿蒙上和在 Android 上没有本质区别。我第一轮引入大概 80% 的组件,编译阶段一个错都没报。
但有几个例外,印象最深的是字体。bones_ui 的默认设计里引用了 FontAwesome 图标字体和一套 Google Fonts 的 Web 风格字体。在 Android 上,这些字体资源打包后路径是固定的,Flutter 引擎能自动找到;在鸿蒙上,如果 pubspec.yaml 里 asset 路径声明方式不对,就会出现“字体家族渲染成方块字”或“图标全部变成豆腐块”的问题。
处理方式也不复杂:检查 pubspec.yaml 中的 fonts 和 assets 声明,确保字体文件的路径是相对工程根目录写的,同时确认没有在 assets 目录里放了一个空文件夹。另外,鸿蒙对字体文件的权限要求比 Android 严格,如果字体内嵌在 ohos 原生层而不是 Flutter assets 层,需要额外走一次原生资源透传。
3.2 我的编译报错清单和对应解法
我整理了第一轮编译时遇到的几条典型报错,以及对应的处理思路,给后来者一个参照:
| 报错信息 | 原因 | 处理方式 |
|---|---|---|
| fontFamily not found | assets 中的字体路径未正确声明 | 检查 pubspec.yaml 的 fonts 段,确认路径和文件名大小写 |
| Impeller backend unavailable | 鸿蒙分支的 Impeller 支持未对齐 | 在 Flutter 启动配置中回退到 Skia 渲染,或关闭 Impeller |
| method 'x' not implemented | 组件调用了未在鸿蒙侧实现的平台通道 | 查看组件源码,定位具体 MethodChannel 名,自行补原生实现 |
| MissingPluginException | 对应原生插件未安装 | 在 oh-package.json5 中补充对应的鸿蒙原生插件依赖 |
| RenderFlex overflowed | bones_ui 栅格组件在特定宽度下子组件溢出 | 检查是否忘了注册响应式断点,或屏幕宽度低于支撑的默认断点 |
这些报错里,Most 让人迷惑的是最后还是“RenderFlex overflowed”。我在 Android 上没见过,原因是鸿蒙平板的窗口初始宽度返回时机比 Android 晚,bones_ui 的栅格系统在拿不到正确宽度时按最窄档渲染,等数据刷新后又跳回正常档,中间就有一帧溢出。解决方法是给栅格组件套一层 AnimatedContainer 固定最小宽度,或者等页面拿到正确尺寸后再渲染 bones_ui 的组件树。
3.3 Impeller 在鸿蒙上的渲染差异
Flutter 从 3.7 之后默认使用 Impeller 渲染引擎,目的很明确——解决 Skia 在部分设备上的 shader 编译卡顿。但在鸿蒙上事情没那么简单,鸿蒙分支的引擎适配进度不完全同步,Impeller 的 Vulkan 后端和鸿蒙图形栈的兼容性没有达到统一标准。
bones_ui 的 Web 风格 UI 大量使用了阴影、圆角、模糊背景和半透明遮罩,这些效果在 Impeller 下的渲染结果和 Skia 下有明显差异。我在真机上测过,某些组件在 Impeller 下阴影变得特别淡,甚至消失,但是回退到 Skia 后视觉就正常了。如果碰上组件“显示不全”但没报错的情况,可以先怀疑是渲染引擎的问题,再怀疑代码。
回退方式一般是在工程入口的 Flutter 配置里设置启动参数,或者在原生壳的初始化代码中指定渲染后端。需要注意的是,如果后续鸿蒙分支升级了对 Impeller 的完整支持,这个回退开关就可以拿掉了。我的建议是先保证功能和视觉正确,渲染引擎的比调优放到后面再说。
4. 原生桥接:EventChannel 与 PlatformView 的鸿蒙化实践
4.1 盘点 bones_ui 哪些能力必须走原生
bones_ui 虽然主打 UI,但难免有几个能力需要读原生能力。我当时的项目里实际碰到了三个:内置 WebView 浏览器弹层、文件下载保存到本地、剪贴板历史读取。
WebView 是最典型的例子。Flutter 本身没有官方 WebView 实现,常见做法是借助 platform_view 把原生 WebView 嵌到 Flutter 页面树里。bones_ui 如果用了这类封装,鸿蒙化就必须处理两件事:一是 ArkUI 原生侧的 Web 组件怎么创建出来并嵌入 Flutter 的视图层级,二是 Flutter 侧的页面生命周期事件怎么传递给原生 WebView。
文件下载保存看着简单,实际上也踩坑。鸿蒙的文件存储权限体系有自己的目录划分,不能直接按 Android 的外部存储路径来写。当时我是查了鸿蒙的 FileIo 接口才把路径逻辑改对。
4.2 EventChannel 实现 Flutter 与鸿蒙原生的事件流推送
先说明一个概念区分。MethodChannel 是请求-响应模型,Flutter 发起一次调用,原生返回一个结果。EventChannel 是流式推送模型,原生侧持续向 Flutter 侧发送事件,Flutter 侧只需监听。bones_ui 的 WebView 组件需要把页面加载进度、URL 跳转、标题变更这些持续事件同步给 Flutter 层,用 MethodChannel 做会很别扭,每秒钟几十次双向握手不现实,所以 EventChannel 是正确选择。
Dart 侧监听 EventChannel 的标准写法:
class BonesWebView { static const EventChannel _progressChannel = EventChannel('bones_ui/webview/progress'); Stream<int> watchProgress() { return _progressChannel.receiveBroadcastStream(); } }鸿蒙原生侧的 ArkTS 实现,需要继承EventChannelHandler,通过sendEvent把进度值持续发出去:
import { EventChannel } from '@ohos/flutter_ohos'; class WebViewEventChannelHandler extends EventChannelHandler { onListen(parameters: any, eventSink: EventSink): void { this.webViewController.loadProgress().then((progress: number) => { eventSink.send(progress); }); } }这里有个特别容易忽略的细节:EventChannel 的onListen和onCancel必须成对维护,否则页面销毁后原生侧继续往外发事件,会出现内存泄漏和“事件发到不存在的监听者”的警告。我在鸿蒙上踩过一次,后来在页面 dispose 时主动调用cancelEventChannel才干净。
4.3 PlatformView 嵌入鸿蒙原生控件的适配要点
另一种场景是把 ArkUI 原生控件直接嵌入 Flutter 页面。bones_ui 的某些高级组件,比如地图图层或者流媒体视图,不一定适合用 Dart 重绘,这个时候就得走 PlatformView。
鸿蒙侧的 PlatformView 适配逻辑,实际上是在原生壳里注册一个平台视图的工厂类,Flutter 引擎通过纹理或者混合视图方式把原生内容合入 Flutter 帧。我适配时最麻烦的不是创建视图本身,而是处理 Flutter 的手势系统与原生控件的手势竞争。
举个例子:Flutter 页面里嵌了一张可拖拽放大的地图,Flutter 的 GestureDetector 会先收到手指事件,如果手势被 Flutter 消费了,原生地图的拖拽就收不到事件。这个问题在 Android 上也存在,鸿蒙上因为事件分发机制的差异,表现得更明显。我当时通过给 Flutter 侧包了一个 Listener,通过behavior: HitTestBehavior.translucent并设置手势优先级来解决,调了一下午。
另一个坑是生命周期。Flutter 页面切到后台再回来,PlatformView 偶尔会变成黑块。根因是原生视图的 Surface 在页面暂停时被回收,恢复后 Flutter 没有重新请求渲染。处理方式是监听 AppLifecycleState,在 resumed 状态手动通知原生视图刷新。
4.4 桥接层最容易翻车的三点
把经验浓缩一下,桥接层最容易翻车的三个点,值得单独记下来:
- 线程问题:原生侧的回调未必在 UI 线程,EventChannel 发送事件时必须确保切换到主线程,否则 Flutter 侧会丢数据。
- 命名空间冲突:多个插件用了同一个 MethodChannel name,后者会静默覆盖前者,表现为“方法调用没有响应但不报错”。我遇到过一次,查了很久才发现是名字撞了。
- 内存泄漏:EventChannel 和 PlatformView 都有原生对象生命周期,Flutter 页面销毁时如果不主动解绑,原生侧资源不会自动释放。
Bridge 一旦做成,后面 UI 层面才能真正进入体验打磨阶段。
5. 交互体验差异:滚动、悬停、焦点和响应式布局的修正
5.1 响应式网格在鸿蒙平板上为什么会错位
bones_ui 的响应式网格依赖 MediaQuery 的宽度来做断点判断。在 Android 上一切正常,到鸿蒙平板上第一次出现错位时,我一度以为是库的 bug,后来定位到原因:鸿蒙在应用启动初期,窗口尺寸返回并不稳定,有时候会先返回一次竖屏尺寸,再由系统切到横屏,MediaQuery 随之更新。这个更新会触发重建,但如果组件没有处理好尺寸变化,就会有一帧错位。
解决思路是给根组件包一个 Builder,在 build 里读取 MediaQuery.size.width,然后做一个简单的防抖:当宽度变化幅度超过某个阈值时才重新布局。bones_ui 的栅格组件本身有响应式逻辑,但需要外部提供一个相对稳定的宽度源,这个坑本质上属于“原生窗口信息返回时序”的问题。
5.2 鼠标悬停和焦点:从触屏思维切到桌面思维
鸿蒙 PC 和鸿蒙平板的交互模型差异很大。手机和平板默认是触摸优先,PC 上鼠标悬停、右键菜单、键盘焦点移动都成了核心交互。bones_ui 的 Web 风格组件在设计时考虑了 hover 状态,但 Flutter 默认情况下并不会主动触发 MouseRegion 的监听,需要手动为组件包一层鼠标事件处理。我在适配导航菜单时发现,鼠标放到菜单上,没有预期的亮起效果,看起来就很“不 Web”。
解决办法分两层。第一层是给 bones_ui 的组件包上 MouseRegion,通过 onEnter/onExit 切换 hover 状态;第二层是在全局主题里定义 hover 样式,让所有可点击组件统一呈现。bones_ui 如果支持主题定制,这步会轻松很多。键盘焦点这块,鸿蒙原生对 Tab 键事件和 Flutter 的焦点系统也有对接差异,我用 Shortcuts 和 Focus 组件把可访问性焦点流理顺了才舒服。
5.3 滚动阻尼和惯性差异
鸿蒙的原生滚动感觉和 Android、iOS 都不太一样,尤其是触控板设备,滚轮的增量颗粒度非常细。Flutter 默认的 ScrollBehavior 是通用平顺阻尼,但到鸿蒙触控板上,向上滚两格页面只动一点,显得很“肉”。
我的调整方案是在 MaterialApp 的 scrollBehavior 里自定义一个 ScrollBehavior,把 mouse 设备的 scrollPhysics 换成 BouncingScrollPhysics,并调大 velocity。这样桌面端滚轮的响应速度明显快了,同时保留触摸屏上的阻尼手感。这个改动不针对 bones_ui 本身,但对整体 UI 的“响应式体验”影响很大——用户第一眼看不出来,但用起来会觉得“顺手”。
5.4 字体:为什么界面少了“Web 味道”
bones_ui 的 Web 风格 UI 默认用的是类 Inter 的字体,观感精致、横向空间紧凑。搬到鸿蒙之后,如果不显式指定 fontFamily,Flutter 会使用鸿蒙内置的 HarmonyOS Sans。这套字体在中文场景下没问题,但搭配数字、英文和图标时,整体味道立刻从“Web 后台”变成“系统原生应用”。
要找回 Web 味道,最直接的办法是把目标字体作为 assets 打进包里,然后在 ThemeData 里统一设置 fontFamily。注意中英文混排场景要处理 fallback,否则中文会用默认字体兜底,导致某些行间距异常。bones_ui 的表格组件对行高特别敏感,字体一变,表格行高可能溢出。这块优化没有技术难度,但最影响视觉达成度。
6. 性能实测与调优:从“能显示”到“跟手”
6.1 卡顿定位:用性能面板锁定 rebuild 热点
bones_ui 接入跑通后,随之而来的是“能跑”和“好用”之间的巨大差距。我先在一个统计页面里遇到了滚动卡顿,帧率目测只有 30 不到,页面里放了一个数据表格、四个卡片和两个图表组件。
先用鸿蒙分支带的 DevTools 做了一次性能录制,发现最耗时的不是 draw 而是 build,也就是 rebuild 太频繁。数据表格每滚动一次,绑定的视图模型就会 setState 触发整页重建,bones_ui 的网格布局再把所有子组件重新布局一遍。定位到问题后,我把表格数据流切成独立的 StreamBuilder,不再通过页面级 setState 传递,同时给卡片组件包上了 RepaintBoundary,把重绘隔离在各自图层内。
6.2 我实测的一组关键数据对比
以下数据来自我手头的一台鸿蒙平板,没有严格做成基准测试,但是能表达调优前后的相对差距:
| 指标 | 优化前 | 优化后 |
|---|---|---|
| 首屏渲染时间(粗测) | 约 2.8s | 约 1.6s |
| 滚动帧率(地图+表格页) | 约 28fps | 约 52fps |
| 峰值内存(含图片缓存) | 约 620MB | 约 470MB |
| 新路由切换完成时间 | 约 1.2s | 约 0.6s |
首屏耗时下降主要是做了“按需加载”:首帧只渲染导航和统计卡片,数据表格、图表、WebView 全部延迟到首帧之后加载。内存下降则是把商品列表的图片缓存策略从“全量预加载”改成了“可视区域加载”,这个在安卓端大家都懂,但鸿蒙适配时容易忽略,因为缓存在引擎层不互通。
6.3 调优动作清单
把这一轮调优的动作提炼一下,共五条:
- 隔离重建:能切 StreamBuilder 的就切 StreamBuilder,不要再走页面级 setState。
- 包 RepaintBoundary:bones_ui 的卡片、面板、表格各自包一层,滚动时避免整页重绘。
- 延迟加载:首帧只渲染关键 UI,重组件用 FutureBuilder 延迟挂载。
- 图片缓存:长列表图片用 cacheExtent 控制预加载范围,避免内存突然飙升。
- 阴影适度:Web 风格 UI 喜欢用阴影层次,但阴影在鸿蒙 Skia 上的渲染成本不低,静态元素的阴影优先用图片替,动态悬浮阴影保留。
调优到这一步,整个页面才真正有了“Web 风格 UI 的顺滑感”,而不是一个勉强跑起来的移植壳。
6.4 关于“响应式体验”的最终判断
我自己用的判断标准很简单:是不是感觉不到布局重新计算的存在。窗口变大变小、横竖屏旋转、内容加载前后,界面都应该是连续变化的,而不是跳变,也不该有某一帧明显卡住。bones_ui 的响应式网格本身提供了一套框架,真正决定体验上限的,是外部给它的数据流是否稳定、渲染边界是否清晰。把这两件事处理好,这套 Web 风格 UI 在鸿蒙上才能算“站住了”。
最后说一个我自己踩出来的经验:鸿蒙化适配不要一上来就追求所有组件一次到位,先把 bones_ui 的核心面板、导航和响应式栅格在真机上跑稳,再逐步引入 WebView、图表这类重组件。平台移植最忌一步到位,每引入一个带原生依赖的组件就完整回归一轮,看着慢,实际反而是最快的路径。如果你也在做 Flutter 三方库的鸿蒙化,按这个顺序过一遍,大概率能少走我那些弯路。