
当 Flutter 遇上鸿蒙:一次“杂志级”混合开发实战
在鸿蒙上将 Flutter 与 WebView 组合到同一页面,需要处理页面衔接、双向通信和原生能力调用等问题。这次实践以一个“产品杂志”风格的商品详情 Demo 为例,逐一验证这些技术链路。先来看最终效果。
以下正文同步自 InfoQ 中国,版权归原站所有,已转换为易读排版。
在鸿蒙上将 Flutter 与 WebView 组合到同一页面,需要处理页面衔接、双向通信和原生能力调用等问题。这次实践以一个“产品杂志”风格的商品详情 Demo 为例,逐一验证这些技术链路。先来看最终效果。
这是一个"产品杂志"风格的商品详情 Demo:Flutter 负责页面骨架、导航栏、商品信息头和底部操作栏;页面中部的图文详情是一个真正的 WebView,由鸿蒙 ArkWeb 内核渲染;两者通过一套自研的双向 JSBridge 通信,H5 还能直接调用登录态、设备信息、原生 Toast、文件选择器等系统能力。
首页是一个沉浸式封面卡片列表。没有传统的"图片九宫格 + 底部 TabBar"电商模板,而是一页杂志式的内容流:顶部是品牌刊头 HARMONY DESIGN LAB,一行 31px 的粗衬线大标题"值得被认真使用的日常好物。",下面跟一句克制的导语和三个胶囊分类筛选(精选 / 音频 / 智能生活):
进入详情页,导航栏写着 PRODUCT JOURNAL——不是"商品详情"这种平庸的命名,而是一份"产品志"。原生区域展示品牌小标(静谧科技 · DAILY RITUAL)、商品名、核心卖点和价格;往下是 H5 渲染的沉浸式封面卡片,深蓝渐变底上写着"在安静与专注之间,重新找到自己的节奏。":
继续滚动,H5 部分有四格大数字数据指标(48dB / 38h / 4.8g / 5.4)、"被认真设计的细节"三宫格、产品故事引用排版,以及一个可折叠的 NATIVE CAPABILITY LAB 技术面板——整个页面看起来完全不像一个"技术 Demo",更像某个新消费品牌的产品官网:
点"加入购物车"→ 原生 SnackBar 计数 + H5 日志面板实时收到 cartChanged 事件;
点"立即体验"→ H5 收到 Flutter 推送的 orderCreated 事件;
这篇文章就完整记录它是怎么从零做出来的,包括中间踩过的两个大坑——一个让应用启动即崩,一个让复制功能静默失败。
改样式不用发版。电商大促期间详情页一天改三次排版,走原生渲染每次都要过审核;H5 内容放 CDN 或打进 assets,改完即生效。
团队分工友好。运营活动页、品牌故事这类内容,写 HTML/CSS 的同学比写 Flutter 的同学好找得多。
排版自由度。引用块、数据网格、折叠面板这类富文本版式,CSS 十几行就能做精致,Flutter 里要手写一堆 Widget。
而商品信息头、底部操作栏这类高频交互区域保留 Flutter 原生渲染:滚动跟手、动画流畅、能直接复用路由和状态管理。于是页面结构就定了:
┌─────────────────────────┐│ AppBar(Flutter 原生) │ ← PRODUCT JOURNAL + 复制 Token├─────────────────────────┤│ 商品信息头(Flutter 原生) │ ← 名称 / 卖点 / 价格├─────────────────────────┤│ ││ WebView + H5 详情 │ ← ArkWeb 内核渲染│ (封面/指标/故事/面板) ││ │├─────────────────────────┤│ 底部操作栏(Flutter 原生) │ ← 客服 / 购物车 / 立即体验└─────────────────────────┘
复制代码
对应的 Flutter 代码骨架非常干净——一个 Column,中间用 Expanded 让 H5 吃掉剩余空间:
// lib/pages/product_detail_page.dart(节选)@overrideWidget build(BuildContext context) { return Scaffold( backgroundColor: const Color(0xFFF5F3EE), appBar: AppBar( title: const Text('PRODUCT JOURNAL'), centerTitle: false, titleSpacing: 20, actions: [ IconButton(onPressed: _copyToken, icon: const Icon(Icons.vpn_key_outlined, size: 19)), ], ), body: Column( children: [ _buildNativeHeader(), // 原生商品信息头 Expanded( // H5 区域:填满剩余空间 child: ClipRRect( borderRadius: const BorderRadius.vertical(top: Radius.circular(12)), child: H5Container( key: _h5Key, assetPath: 'assets/h5/detail.html', onBridgeReady: _installBridge, // Bridge 装配点 ), ), ), _buildNativeBottomBar(), // 原生底部操作栏 ], ), );}
复制代码
关键点只有一个:三段式页面里,中间那段是 WebView,上下两段是 Flutter,用户看起来必须像一页。 这对设计语言的一致性要求极高——第五节会专门讲我们怎么统一视觉。另外注意 ClipRRect 给 WebView 顶部加了 12px 圆角,这个小细节让 H5 区域看起来像一张"纸",原生和 Web 的边界被柔和地隐藏了。
如果有新手朋友,不会环境配置,可学习官方的教程文档:https://developer.huawei.com/consumer/cn/blog/topic/03210612908817289
Demo 第一版跑起来就给了个下马威:应用启动即闪退,DevEco 的 FaultLog 里赫然记录着崩溃,注意几个关键信息:
Module name: com.example.hm_webview_demo——确实是我们的应用;
ReleaseType: debug、Version: 1.0.0——包没问题,是运行时错误。
ReferenceError: cannot find record'pkg_modules/.ohpm/@ohos+flutter_ohos.../index'
复制代码
这个错误翻译成人话就是:ArkTS 运行时在加载模块时,找不到 @ohos/flutter_ohos 这个依赖的打包记录。 逐层排查后发现三个问题叠加:
打开 ohos/entry/oh-package.json5,dependencies 字段居然是空的——@ohos/flutter_ohos 只存在于旧的 oh-package-lock.json5 和 hvigor 构建缓存里。之前能跑,是因为缓存一直没失效;一旦执行了 clean 或换了构建参数,运行时就直接找不到模块。
这是鸿蒙工程和 Android Gradle 的一个心态差异:Gradle 里 settings.gradle 少声明依赖基本跑不起来,而 ohpm 的缓存机制太"宽容",会把问题推迟到运行时才爆炸。修复方式是把所有运行时依赖显式声明:
// ohos/entry/oh-package.json5 —— 显式声明所有运行时依赖{ "name": "entry", "version": "1.0.0", "dependencies": { "@ohos/flutter_ohos": "file:../har/flutter_embedding_debug.har", "flutter_native_x86_64": "file:../har/flutter_native_x86_64.har", "hm_bridge": "file:../hm_bridge", "webview_flutter_ohos": "file:./oh_modules/.ohpm/..." }}
复制代码
@ohos/flutter_ohos:Flutter 的鸿蒙 embedding(HAR 包),Dart 引擎和 PlatformView 的宿主;
flutter_native_x86_64:模拟器专用的 native 引擎产物,见问题 3;
webview_flutter_ohos:鸿蒙化版本的 WebView 插件(社区 tpc 仓库,Git 依赖方式引入)。
声明补上后还不能直接构建——旧的 oh_modules 和 lockfile 里仍挂着失效的解析记录。老老实实做全套清理:
清理依赖与构建缓存Remove-Item -Recurse -Force ohos\entry\buildRemove-Item -Recurse -Force ohos\entry\oh_modulesRemove-Item -Force ohos\entry\oh-package-lock.json5hvigorw --stop-daemon # 停掉 hvigor 守护进程,防止缓存复活重新解析依赖flutter pub get
复制代码
第一次重新构建,hvigor 直接报错失败,错误码 00308018,Command 指向 flutter assemble 子进程:
结合 FaultLog 里的 CpuAbi: x86_64 才反应过来:模拟器是 x86_64 架构,而本地 Flutter embedding 曾混用过 ohos-arm64 的产物。鸿蒙 Flutter 的构建产物必须区分架构:
统一换成 x86_64 的 embedding HAR 后,重新走 clean + assembleHap,53 个任务全部通过,15 秒构建成功:
Initialize: pages/Indexload content end, ret=0NotifyCompleteFirstFrameDrawing
复制代码
本节经验总结:1. 鸿蒙 Flutter 工程的 oh-package.json5 依赖一定要显式声明,不要相信 lockfile 和缓存;2. 模拟器(x86_64)和真机(arm64)的 HAR 产物不能混用,FaultLog 的 CpuAbi 字段是第一现场;3. 遇到 hvigor 报 Unknown Error 时,先用 hilog 拿运行时第一现场,再回溯构建链路——崩溃日志里的信息量远比构建日志大。
注意这个报错的措辞——它正是我们 Dart 侧自己定义的。鸿蒙化的剪贴板插件早期实现里,copyToClipboard 的 ArkTS 侧没有把结果正确回传,MethodChannel.invokeMethod 拿到的是 null。如果代码写成"拿到 null 就当成功",这个 bug 就会被静默吞掉,用户以为复制成功了,粘贴板里却什么都没有——这类静默失败比崩溃更难排查。
// hm_bridge/lib/hm_bridge.dart(节选)static Future<T> _invoke<T>(String method, [Map<String, Object?>? args]) async { try { final result = await _channel.invokeMethod<T>(method, args); if (result == null) { // 关键:成功通道返回 null 也算失败,绝不让静默失败穿透到业务层 throw PlatformException( code: 'null_result', message: '原生返回 null: $method', ); } return result; } on MissingPluginException { // 非 ohos 平台(如桌面调试)没有原生实现,给出明确错误而非静默失败 throw UnsupportedError('hm_bridge 仅实现了 ohos 平台'); } on PlatformException catch (e) { throw HmBridgeException(code: e.code, message: e.message ?? '未知原生错误'); }}
复制代码
// lib/pages/product_detail_page.dart(节选)Future<void> _copyToken() async { if (_token.isEmpty) return; try { await HmBridge.copyToClipboard(_token); if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Token 已复制')), ); } on UnsupportedError { // 桌面调试降级:走 Flutter 自带剪贴板 await Clipboard.setData(ClipboardData(text: _token)); } on HmBridgeException catch (e) { // 原生失败:明确告知用户,绝不静默 ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('复制失败:${e.message}')), ); }}
复制代码
修复原生侧回传后再次点击,底部 SnackBar 干净利落地弹出"Token 已复制":
本节经验总结:Platform Channel 的错误处理要遵守两条铁律——原生成功但无返回值时,回传一个约定的成功对象而不是 null;Dart 侧把 null 视为异常。这两条加上 HmBridgeException 的统一错误码(PERMISSION_DENIED / NOT_SUPPORTED / ...),让上层 UI 永远能区分"没权限"、"没实现"和"真失败"三种情况。
为了让混合页面在视觉上保持统一,我们从配色、字体、卡片布局和原生组件样式入手调整了整套 UI。下面结合代码介绍具体实现。
红色促销感全部拿掉,价格用主色加粗呈现。配色在全局主题一次定义,全 App 生效:
// lib/main.dartMaterialApp( debugShowCheckedModeBanner: false, theme: ThemeData( useMaterial3: true, fontFamily: 'sans', scaffoldBackgroundColor: const Color(0xFFF5F3EE), colorScheme: ColorScheme.fromSeed( seedColor: const Color(0xFF172033), brightness: Brightness.light, surface: const Color(0xFFF5F3EE), ), appBarTheme: const AppBarTheme( backgroundColor: Color(0xFFF5F3EE), foregroundColor: Color(0xFF172033), surfaceTintColor: Colors.transparent, // 关键:去掉 M3 的染色 elevation: 0, ), ), home: const ProductListPage(),)
复制代码
一个容易忽略的细节:surfaceTintColor: Colors.transparent 必须显式设置,否则 Material 3 的 AppBar 在滚动时会自动染上一层主色调的紫色,整个"米白 + 墨蓝"的调性瞬间被破坏。
页面大标题:31px / 800 / 负字距(letterSpacing: -1.2)
// lib/pages/product_list_page.dart(节选)Container( height: 168, decoration: BoxDecoration( borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), gradient: LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [product.color, const Color(0xFF263A50)], // 品牌色 → 墨蓝渐变 ), ), child: Stack(children: [ // 左上角:品类编号,杂志目录的仪式感 Positioned(top: 18, left: 20, child: Text(product.index)), // "AUDIO / 01" // 右上角:VIEW STORY ↗ 半透明白胶囊 Positioned(right: 20, top: 16, child: _viewStoryChip()), // 居中:大号商品剪影图标(代替实拍图,零素材成本) const Center(child: Icon(Icons.headphones_rounded, size: 68)), // 底部:一句大号核心卖点 Positioned(bottom: 18, left: 20, child: Text(product.desc)), // "48dB 深度降噪" ]),),
复制代码
列表本身用 CustomScrollView + SliverList 而不是普通 ListView,为的是让刊头(大标题 + 导语 + 筛选器)和卡片列表共享同一个滚动体系——滚动时刊头自然上移,整页像一本真的杂志被翻动:
CustomScrollView( slivers: [ SliverToBoxAdapter(child: buildIntro(context)), // 刊头 SliverPadding( padding: const EdgeInsets.fromLTRB(20, 8, 20, 32), sliver: SliverList.separated( itemCount: products.length, separatorBuilder: (, __) => const SizedBox(height: 16), itemBuilder: (, i) => _ProductCard(product: _products[i]), ), ), ],)
复制代码
// lib/pages/product_detail_page.dart(节选)Widget _buildNativeHeader() { return Container( color: const Color(0xFFF5F3EE), padding: const EdgeInsets.fromLTRB(20, 8, 20, 16), child: Row(children: [ Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text('静谧科技 · DAILY RITUAL', // 金色品牌小标 style: TextStyle(fontSize: 10, letterSpacing: 1.3, color: Color(0xFFA47A43), fontWeight: FontWeight.w800)), const SizedBox(height: 7), const Text('静音无线降噪耳机 Pro', style: TextStyle(fontSize: 19, fontWeight: FontWeight.w800, color: Color(0xFF172033))), const SizedBox(height: 5), Text('48dB 深度降噪 · 38 小时续航', style: TextStyle(fontSize: 11, color: Colors.grey.shade600)), ])), const SizedBox(width: 12), Column(crossAxisAlignment: CrossAxisAlignment.end, children: [ const Text('¥299', style: TextStyle(fontSize: 23, fontWeight: FontWeight.w800, color: Color(0xFF172033))), Text('原价 ¥499', style: TextStyle(fontSize: 10, color: Colors.grey.shade500, decoration: TextDecoration.lineThrough)), ]), ]), );}
复制代码
详情页最考验设计一致性:上面是 Flutter 画的,下面是 H5 画的。我们的做法是把三个主题色作为"契约"同步给 H5——H5 的封面卡片直接复用 #172033 → #263A50 的渐变和金色小标,滚动时视觉上完全连续:
H5 内部的数据指标做成两列大数字网格(display: grid; grid-template-columns: 1fr 1fr),品牌故事用引用块排版:
底部操作栏换成墨蓝深色底、琥珀金按钮,与页面的米白形成收尾对比。注意角标颜色也换成了金色配深色文字——红色角标是电商惯性,金色角标才是"产品志"该有的样子:
一句话方法论:高级感 = 克制的配色 + 大胆的字号对比 + 统一的圆角与间距。这三个 Demo 全程没有用一张图片素材,纯代码 + 系统图标就做到了能上文章封面的水平。
页面的视觉效果之外,更关键的是打通 Flutter 与 H5 之间的双向通信链路,让 H5 能够调用原生能力,Flutter 也能主动向 H5 推送事件。围绕这条链路,需要解决消息分发、请求与响应配对、异常处理等一系列问题,这也是整个 Demo 技术实现中最核心的部分。
很多 JSBridge 教程会让你注册一堆 JavaScriptChannel。我们反其道而行——只注册一个通道,所有业务方法在通道内按 method 字段分发:
// lib/web/js_bridge.dart(节选)class JsBridge { JsBridge({required this.controller}); final WebViewController controller; // 通道名必须与 H5 中的注入对象完全一致 static const String channelName = 'HmBridge'; final Map<String, Future<Object?> Function(BridgeRequest)> _handlers = {}; void register(String method, Future<Object?> Function(BridgeRequest) handler) { _handlers[method] = handler; } void attach() { controller.addJavaScriptChannel(channelName, onMessageReceived: (m) => _dispatch(m.message)); }}
复制代码
鸿蒙上每个 JavaScriptChannel 都对应一次原生对象注入,通道越少开销越小;
单通道 + 方法名分发,等价于一条总线,日志里 方法名 + #序号 就能完整追踪一次调用;
{ "id": 3, "method": "getDeviceInfo", "params": {} }
id 由 H5 侧递增生成,Flutter 回填结果时原样带回,形成 Promise 式的请求-响应配对。Dart 侧解析时有一个重要防御——H5 内容不可控,一条脏消息不应该让页面崩溃:
// 解析失败返回 null 而不是抛异常static BridgeRequest? tryParse(String raw) { try { final decoded = jsonDecode(raw); if (decoded is! Map<String, dynamic>) return null; final id = decoded['id']; final method = decoded['method']; if (id is! int || method is! String) return null; return BridgeRequest(id: id, method: method, params: decoded['params'] is Map<String, dynamic> ? decoded['params'] : const {}); } catch (e) { debugPrint('[Bridge] 非法消息: $raw'); return null; }}
复制代码
分发逻辑里,未注册的方法返回 404 错误码,业务异常转 500,绝不让异常穿透到 WebView:
Future<void> _dispatch(String raw) async { final request = BridgeRequest.tryParse(raw); if (request == null) return; final handler = _handlers[request.method]; if (handler == null) { await _reply(request.id, { 'code': BridgeCode.unknownMethod, // 404 'message': '未注册的方法: ${request.method}', }); return; } try { final data = await handler(request); await _reply(request.id, {'code': BridgeCode.ok, 'data': data}); } catch (e) { await _reply(request.id, {'code': BridgeCode.nativeError, 'message': e.toString()}); }}
复制代码
回填结果给 H5 时藏着一个经典陷阱。结果要拼进 runJavaScript 的脚本文本里,如果直接把 JSON 拼进源码,内容里的单引号、换行符会直接把脚本炸成 SyntaxError:
Future<void> _reply(int id, Map<String, Object?> payload) async { final jsonStr = jsonEncode(payload); final escaped = jsonEncode(jsonStr); // 二次编码 → 合法的 JS 字符串字面量 await controller.runJavaScript( 'window.__bridgeCallback && window.__bridgeCallback($id, $escaped);');}
复制代码
json
正文由 探How 从来源站点 RSS 同步,内容未经改写;遇到排版缺失或需要图片、视频时请以原文为准。