Flutter混编鸿蒙WebView:商品详情页实战与两个崩溃坑
一、当商品详情页有了「杂志感」 在鸿蒙上将 Flutter 与 WebView 组合到同一页面,需要处理页面衔接、双向通信和原生能力调用等问题。这次实践以一个“产品杂志”风格的商品详情 Demo 为例,逐一验证这些技术链路。先来看最终效果。 这是一个"产品杂志"风格的商品详情 Demo:Flutter 负责页面骨架、导
一、商品详情页也能做出「杂志感」
在鸿蒙上把 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」,倒像某个新消费品牌的产品官网:
可它的内核是硬的,每个交互都在验证一条技术链路:
-
点右上角钥匙图标,鸿蒙原生剪贴板写入 Token(坑二的现场);
-
点「加入购物车」,原生 SnackBar 计数,H5 日志面板实时收到 cartChanged 事件;
-
点「立即体验」,H5 收到 Flutter 推来的 orderCreated 事件;
-
在 H5 技术面板里点任意按钮,走一遍完整的请求-响应式桥接调用,日志实时打印。
这篇文章就把它是怎么从零做出来的完整记下来,中间踩过的两个大坑也一并写:一个让应用一启动就崩,一个让复制功能静默失败。
二、技术方案:为什么选「Flutter + WebView」混合
在鸿蒙上做 Flutter 应用,一般有三条路线:
商品详情页为什么天然适合 H5?有三个实打实的理由:
-
改样式不用发版。电商大促期间,详情页一天改三次排版,走原生渲染每次都得过审核;H5 内容放 CDN 或者打进 assets,改完即生效。
-
团队分工也友好。运营活动页、品牌故事这类内容,写 HTML/CSS 的同学比写 Flutter 的同学好找得多。
-
排版自由度更高。引用块、数据网格、折叠面板这类富文本版式,CSS 十几行就能做精致,放到 Flutter 里得手写一堆 Widget。
商品信息头、底部操作栏这类高频交互区域则保留 Flutter 原生渲染:滚动跟手、动画流畅,路由和状态管理也能直接复用。页面结构就这么定下来了:
┌─────────────────────────┐│ AppBar(Flutter 原生) │ ← PRODUCT JOURNAL + 复制 Token├─────────────────────────┤│ 商品信息头(Flutter 原生) │ ← 名称 / 卖点 / 价格├─────────────────────────┤│ ││ WebView + H5 详情 │ ← ArkWeb 内核渲染│ (封面/指标/故事/面板) ││ │├─────────────────────────┤│ 底部操作栏(Flutter 原生) │ ← 客服 / 购物车 / 立即体验└─────────────────────────┘
对应的 Flutter 代码骨架很干净——一个 Column,中间用 Expanded 让 H5 吃掉剩余空间:
@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( child: ClipRRect( borderRadius: const BorderRadius.vertical(top: Radius.circular(12)), child: H5Container( key: _h5Key, assetPath: 'assets/h5/detail.html', onBridgeReady: _installBridge, ), ), ), _buildNativeBottomBar(), ], ), );}
三段式页面中,只有中间那段由 WebView 承载,上下两段则跑在 Flutter 上,可到了用户眼里,它得是一整页。视觉统一因此成了硬要求,第五节会细说我们怎么把设计语言捏到一块儿。还有个细节:ClipRRect 在 WebView 顶部切出 12px 圆角,H5 区域于是像一张「纸」,原生与 Web 之间的接缝被悄悄磨平了。
三、故障排查一:运行时cannot find record,应用秒崩
Demo 第一版跑起来就给了个下马威:应用启动即闪退,DevEco 的 FaultLog 里明明白白记着崩溃,几个关键信息得留意:
-
Module name: com.example.hm_webview_demo——确实是我们的应用;
-
CpuAbi 字段显示 x86_64,说明跑的是模拟器。这个细节后面还会牵出另一个麻烦。
-
ReleaseType 为 debug、Version 是 1.0.0,包本身没毛病,出问题的是运行阶段。
只盯着崩溃日志看不出所以然,改用 hilog 按应用进程过滤,才把真正的第一现场捞出来:
ReferenceError: cannot find record'pkg_modules/.ohpm/@ohos+flutter_ohos.../index'
换成直白的话说,ArkTS 运行时在加载模块时,没找到 `@ohos/flutter_ohos` 这个依赖的打包记录。一层层往下查,三个问题叠在了一起:
问题 1:依赖声明是空的,全靠缓存续命
翻看 ohos/entry/oh-package.json5,dependencies 字段里空无一物——@ohos/flutter_ohos 只在旧的 oh-package-lock.json5 和 hvigor 构建缓存中留有痕迹。此前能正常跑,是缓存始终没失效;可一旦执行 clean 或者换了构建参数,运行时就会直接报找不到模块。
这算是鸿蒙工程和 Android Gradle 在心态上的一个差别:Gradle 里 settings.gradle 漏写依赖,基本跑不起来;ohpm 的缓存机制却太“宽容”,硬把问题拖到运行时才爆。修法是把所有运行时依赖都显式写清楚:
{ "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 里会细说;
-
hm_bridge:自研的桥接插件,用本地 path 引用;
-
webview_flutter_ohos:鸿蒙化版本的 WebView 插件,来自社区 tpc 仓库,走 Git 依赖方式引入。
问题 2:构建缓存污染
依赖声明补齐了,也不能马上构建——旧的 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
问题 3:ABI 混用(最隐蔽的一个)
第一次重新构建,hvigor 直接报错失败,错误码 00308018,Command 指向 flutter assemble 子进程:
再对上 FaultLog 里的 CpuAbi: x86_64,才明白过来:模拟器是 x86_64 架构,而本地 Flutter embedding 之前混用过 `ohos-arm64` 的产物。鸿蒙 Flutter 的构建产物,架构必须分清楚:
全部换成 x86_64 的 embedding HAR 之后,重新走 clean + assembleHap,53 个任务全部通过,15 秒构建成功:
重新安装启动,hilog 显示首帧绘制正常:
Initialize: pages/Indexload content end, ret=0NotifyCompleteFirstFrameDrawing
ReferenceError 不再出现。
这一节的几条经验:1. 鸿蒙 Flutter 工程的 oh-package.json5 依赖必须显式声明,lockfile 和缓存都靠不住;2. 模拟器(x86_64)和真机(arm64)的 HAR 产物不能混着用,FaultLog 的 CpuAbi 字段就是第一现场;3. hvigor 报 Unknown Error 时,先用 hilog 拿到运行时第一现场,再回头查构建链路——崩溃日志里的信息量比构建日志大得多。
四、故障排查二:剪贴板返回 null,复制静默失败
UI 跑通之后,详情页右上角的“复制 Token”按钮却弹出了提示:
复制失败:原生返回 null: copyToClipboard
细看这条报错,措辞其实是 Dart 侧自己写下的。鸿蒙版剪贴板插件早期那版里,copyToClipboard 在 ArkTS 一侧没把结果正确传回来,MethodChannel.invokeMethod 收到的是 null。要是代码把“拿到 null”当成“成功”,这个 bug 就被悄悄咽下去了,用户以为复制完成,剪贴板里却空空如也。这类静默失败查起来比崩溃还费劲。
于是我们在插件层把“null 即异常”定死成一条契约:
static Future<T> _invoke<T>(String method, [Map<String, Object?>? args]) async { try { final result = await _channel.invokeMethod<T>(method, args); if (result == null) { throw PlatformException( code: 'null_result', message: '原生返回 null: $method', ); } return result; } on MissingPluginException { throw UnsupportedError('hm_bridge 仅实现了 ohos 平台'); } on PlatformException catch (e) { throw HmBridgeException(code: e.code, message: e.message ?? '未知原生错误'); }}
UI 层按异常类型分别做降级:
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 { await Clipboard.setData(ClipboardData(text: _token)); } on HmBridgeException catch (e) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('复制失败:${e.message}')), ); }}
把原生侧的回传修好后再点一次,底部 SnackBar 干脆地弹出“Token 已复制”:
这一节的经验是两条:原生调用成功却没有返回值时,回传一个事先约定的成功对象,别给 null;Dart 侧则把 null 一律当异常。再配上 HmBridgeException 的统一错误码(PERMISSION_DENIED / NOT_SUPPORTED / ...),上层 UI 就能分得清“没权限”“没实现”和“真失败”这三种情况。Platform Channel 的错误处理,守住这两条就够了。
五、UI 设计:让 Demo 看起来“不像 Demo”
混合页面要在视觉上不打架,我们动了配色、字体、卡片布局和原生组件样式这几处,把整套 UI 重新调了一遍。具体做法下面结合代码说。
-
配色收敛:全App只留三种颜色
-
底色用 #F5F3EE,暖米白,替掉纯白
-
主色用 #172033,墨蓝黑,替掉 Material 默认蓝
-
点缀色 #C89B5B,琥珀金,只出现在按钮、高亮和品牌小标上
促销味儿的红色一概不用,价格改成主色加粗。这些颜色在全局主题里定义一次,全 App 就都生效:
MaterialApp( 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, elevation: 0, ), ), home: const ProductListPage(),)
有个细节容易被漏掉:surfaceTintColor: Colors.transparent 得显式写上。不写的话,Material 3 的 AppBar 一滚动就会自动罩上一层主色调的紫,“米白 + 墨蓝”的味道立刻被搅乱。
-
字体排印:字号就是层级
-
页面大标题:31px / 800 / 负字距(letterSpacing: -1.2)
-
英文小标:10-11px / 1.3~2.1 倍字距 / 全大写
-
正文:14-15px / 1.65 倍行高
-
辅助说明:11-12px / 灰阶
杂志感的关键其实在英文小标(PRODUCT JOURNAL、DAILY RITUAL、VIEW STORY ↗、READ MORE →)上,几乎不花成本,观感却提升明显。它们干的是“视觉锚点”的活:信息交给中文,氛围交给英文。
-
首页卡片:用封面思路取代商品思路
每个商品做成一张沉浸式封面卡,不再套“图片 + 标题 + 价格”那套电商三件套:
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" 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 深度降噪" ]),),
列表没有用普通 ListView,而是 CustomScrollView + SliverList。这样刊头(大标题 + 导语 + 筛选器)和卡片列表处在同一套滚动体系里:滑动时刊头顺势上移,整页翻起来像一本真杂志:
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]), ), ), ],)
-
原生头部:能少则少
原生商品信息头只留三行,用 Row 分成左右两栏,右侧价格贴底垂直对齐:
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)), ]), ]), );}
-
原生页面和 H5 之间怎么做到看不出接缝?答案在配色变量上——两边的主题色共用同一套值。
详情页最能检验设计是否统一:上半部分是 Flutter 绘制的,下半部分由 H5 渲染。我们的处理方式,是把三个主题色当作"契约"交给 H5 共用——H5 的封面卡片直接沿用 #172033 → #263A50 的渐变与金色小标,滑动过程中视觉上看不出断点:H5 内部的数据指标排成两列大数字网格(display: grid; grid-template-columns: 1fr 1fr),品牌故事部分则以引用块呈现:
底部操作栏用了墨蓝深色打底、琥珀金按钮,与整页的米白形成收束对比。角标颜色也一并换成金色配深色文字——红色角标是电商的惯性做法,金色角标才符合"产品志"的调性:
这套思路可以压缩成一句话:高级感 = 克制的配色 + 大胆的字号对比 + 统一的圆角与间距。三个 Demo 全程没用任何一张图片素材,纯代码加系统图标就做到了能当文章封面的水准。
六、双向通信:JSBridge 才是混合开发的核心
视觉之外,更要紧的是把 Flutter 与 H5 之间的双向通信链路打通,让 H5 能调起原生能力,Flutter 也能主动向 H5 推事件。这条链路上,消息分发、请求与响应的配对、异常处理等问题都要解决,它也是整个 Demo 技术实现里最关键的一块。
6.1 通道设计:只用一个 Channel 分发方法
不少 JSBridge 教程会教你注册一堆 JavaScriptChannel。我们的做法相反——只注册一个通道,所有业务方法都在通道内按 `method` 字段分发:
class JsBridge { JsBridge({required this.controller}); final WebViewController controller; 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 就意味着一次原生对象注入,通道数量越少,开销越低;
-
单通道加方法名分发,效果等同于一条总线,日志里靠 方法名 + #序号 就能把一次调用完整串起来;
-
H5 那边只需认识一个全局对象 window.HmBridge,接入成本最低。
6.2 消息协议:请求与响应严格配对
H5 → Flutter 的每条消息都采用 JSON 格式:
{ "id": 3, "method": "getDeviceInfo", "params": {} }
id 在 H5 侧递增生成,Flutter 回填结果时把它原样带回,从而形成 Promise 式的请求-响应配对。Dart 侧解析时加了一道重要防御——H5 内容不可控,一条脏消息不该把页面搞崩:
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, '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()}); }}
6.3 回填陷阱:JSON 被二次编码
把结果回填给 H5 时有个经典坑。结果要拼进 runJavaScript 的脚本文本,若直接把 JSON 拼进源码,里面的单引号、换行符会让脚本当场报 SyntaxError:
Future<void> _reply(int id, Map<String, Object?> payload) async { final jsonStr = jsonEncode(payload); final escaped = jsonEncode(jsonStr); await controller.runJavaScript( 'window.__bridgeCallback && window.__bridgeCallback($id, $escaped);');}
关键在于 jsonEncode 要套两层:先让 Map 变成 JSON 字符串,再把这个 JSON 字符串转成带转义处理的字符串字面量。emit 那里也用了同一招,见 6.5。
6.4 H5 侧:靠一个 Promise 封装解决全部问题
H5 侧把整条链路收进一个 callNative 函数里,它返回 Promise,并且自带 5 秒超时:
function callNative(method, params, timeoutMs) { return new Promise(function (resolve, reject) { var id = ++_seq; _pending[id] = { resolve: resolve, reject: reject }; var payload = JSON.stringify({ method: method, id: id, params: params || {} }); if (window.HmBridge && typeof window.HmBridge.postMessage === 'function') { window.HmBridge.postMessage(payload); log('→ ' + method + ' #' + id, 'info'); } else { reject(new Error('原生桥未注入')); return; } setTimeout(function ({ if (_pending[id]) { delete _pending[id]; reject(new Error('调用超时: ' + method)); log('× 超时 ' + method, 'err'); } }, timeoutMs || 5000); });} window.__bridgeCallback = function (id, resultJson) { var p = _pending[id]; if (!p) return; delete _pending[id]; var res = typeof resultJson === 'string' ? JSON.parse(resultJson) : resultJson; if (res.code === 0) { p.resolve(res.data); log('← #' + id + ' ok', 'info'); } else { p.reject(new Error(res.message)); log('← #' + id + ' 失败', 'err'); }};
这样一来,业务代码调用起来就很清爽:
callNative('getDeviceInfo').then(function (d) { log('设备: ' + JSON.stringify(d));}).catch(function (e) { log(e.message, 'err'); });
6.5 Flutter → H5:事件推送通道
反方向不用配对,走的是单向广播。由 Flutter 主动执行 runJavaScript,H5 那边注册一个全局回调:
Future<void> emit(String type, [Map<String, Object?> data = const {}]) async { final jsonStr = jsonEncode({'type': type, 'data': data}); final escaped = jsonEncode(jsonStr); await controller.runJavaScript( 'window.__nativeEvent && window.__nativeEvent($escaped);');}点"立即体验"按钮时一行代码触发:ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('订单已提交(观察 H5 是否记录到原生事件)')));_h5Key.currentState?.emit('orderCreated', {'no': 'HM20261030'});
H5 的日志面板马上就能收到事件:
6.6 状态同步:购物车角标
加购这个场景最能说明双向通道的价值:Flutter 侧按钮每点一次计数加一,同时把最新数量推送给 H5:
void _addToCart( { setState(() => _cartCount++); ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('已加入购物车(共 $_cartCount 件)'))); _h5Key.currentState?.emit('cartChanged', {'count': _cartCount});}
原生 SnackBar 的计数和 H5 面板之间是实时联动的:
七、能力实验场:将桥接方法做成可视化验证
为了让所有桥接方法都“亮出来”,H5 底部放了一个 NATIVE CAPABILITY LAB 面板。每个按钮都对应一次真实调用,下面的终端风格日志区会实时打印请求和响应,通信链路一眼就能看清:
四个能力,各自的验证点并不相同:
获取登录 Token(getToken)这一步要看的是 Flutter 侧的业务数据能不能交到 H5 手上。日志里 Token 长度是 23,前缀为 demo-token-8...,可见 H5 拿到的是真实业务数据,并非假回显:
读取设备信息(getDeviceInfo)这一项检验的是自研插件从 MethodChannel 一路走到 ArkTS 系统 API 的整条链路是否通畅。回传的 brand: HUAWEI、sdkVersion: 26 正是模拟器的真实参数:
弹出原生提示(showToast)它要验证的其实是层级关系一件事:在 H5 里按下去,弹出来的是鸿蒙原生 SnackBar,而且必须盖在 WebView 之上。这个用例就是专门拿来暴露 PlatformView 合成模式下的层级问题:
选择本地文件(pickFile)这里看的是 H5 的
图库、拍照、文件三选一,由 ArkWeb 拉起系统级 photoPicker。H5 只抛出一个 DOM 事件,原生就给出三套 UI,桥接层的作用在这一刻变得很具体。
八、桥接层设计:自研 hm_bridge 插件
通信能力都收在自研的 hm_bridge 插件中。鸿蒙 Flutter 插件适配的标准做法也正是这样:
在插件工程根目录执行,自动生成 ohos 平台目录flutter create -t plugin
生成出来的目录里,lib/hm_bridge.dart 提供 Dart API,平台侧的实现则放在 ohos/src/main/ets/ 下,用 ArkTS 编写。整体分层如下:
┌──────────────┐ JavaScriptChannel ┌──────────────┐ MethodChannel ┌──────────┐│ H5 (JS) │ ◄─────────────────► │ Dart 侧 │ ◄─────────────► │ 鸿蒙系统 ││ callNative │ window.HmBridge │ JsBridge │ hm_bridge/ │ 能力 API │└──────────────┘ └──────────────┘ methods └──────────┘ │ │ │ └── 超时兜底 / 错误码约定 ──────────────┴── null 即异常 / 统一异常类型 ──┘
Dart API 被有意做得很"薄",每个方法只用一行文档注释交代来源和注意事项:
class HmBridge { static const MethodChannel _channel = MethodChannel('hm_bridge/methods'); static Future<DeviceInfo> getDeviceInfo(async { final result = await _invoke<Map<Object?, Object?>>('getDeviceInfo'); return DeviceInfo.fromMap(result); } static Future<List<String>> pickImages({int maxCount = 9}) async { final result = await _invoke<List<Object?>>('pickImages', {'maxCount': maxCount}); return result.map((e) => e! as String).toList(growable: false); } static Stream<Map<String, Object?>> get events => _eventController.stream;}
设计上遵循三条原则:
-
所有调用带
reqId请求和响应要一一对上,日志里能逐条核对(getToken #1 → #1 ok),一旦出问题就能锁定是哪一次调用。 -
出错就得明确抛出来:原生侧失败时返回结构化错误码(HmBridgeException),不许丢回一个裸 null,坑二踩过的教训就这样写进了代码。
-
能力走白名单机制:Dart 侧只放开注册过的方法,H5 若调用没注册的能力,拿到的就是 404 错误码,系统能力不会被随意触碰。
插件还顺带处理了 Cookie 的情况——适配包的 setCookie 只是透传,URL 不做归一化,跨域时会悄悄失败,因此插件里明确要求传入完整 url:
static Future<void> setCookie({ required String url, required String name, required String value, String path = '/',}) async { await _invokeVoid('setCookie', {'url': url, 'name': name, 'value': value, 'path': path});}
九、H5 容器:加载态、错误恢复与导航拦截
H5Container 算得上整套混合方案里“可复用资产”的典型。除 Bridge 装配点之外,它的加载态、错误重试和进度条逻辑在 Android、iOS、HarmonyOS 上保持一致,平台差异都收进了 BridgeHandler 注入的回调中。
_controller = WebViewController() ..setJavaScriptMode(JavaScriptMode.unrestricted) ..setNavigationDelegate(NavigationDelegate( onProgress: (p) { if (mounted) setState(() => _progress = p); }, onWebResourceError: (error) { if (error.isForMainFrame == false) return; setState(() { _hasError = true; _errorText = '${error.errorCode} ${error.description}'; }); }, onNavigationRequest: (request) { if (request.url.startsWith('http')) return NavigationDecision.prevent; return NavigationDecision.navigate; }, )) ..loadFlutterAsset(widget.assetPath);
有三个细节值得说一说:
-
进度条:LinearProgressIndicator 高仅 2px,贴在 WebView 顶部,加载结束就隐藏。原生 App 的 WebView 带加载进度是基本功,缺了它就显得掉价。
-
错误区分:在鸿蒙 ArkWeb 上,图片、脚本这类子资源加载失败同样会触发 onWebResourceError,若不判断 isForMainFrame,一张图加载失败就能让整页变成错误页。
-
导航拦截:H5 里的外链统一 prevent,需要跳转的业务交给 Bridge 方法(比如 openUrl)走原生路由,WebView 始终留在 App 的导航栈内。
十、构建与调试工作流
最后聊聊日常开发的工作流,这块官方文档写得比较零散。
双 IDE 配合(DevEco 调不了 Dart):
-
Dart 代码:在 VSCode 或 Android Studio 里直接 debug main.dart;
-
ArkTS 代码:用 DevEco 打开 ohos 目录,配好调试签名后 debug 运行;
-
已经在跑的应用,用 flutter attach 对 Dart 代码做热重载。
模拟器上的注意事项:
-
构建命令得带上平台参数:flutter build hap --target-platform ohos-x64,真机则用 ohos-arm64;
-
依赖的 Flutter embedding HAR 也要按架构区分(坑一的第三个问题);
-
排查 ABI 不匹配,FaultLog 的 CpuAbi 字段是第一现场。
一键构建验证(我们目前在用的命令组合):
环境变量(PowerShell)$env:DEVECO_SDK_HOME = 'D:\DevEco Studio\sdk'$env:JAVA_HOME = 'D:\DevEco Studio\jbr'$env:Path = 'C:\Users\<you>\ohos\flutter_flutter\bin;D:\DevEco Studio\tools\hvigor\bin;' + 'D:\DevEco Studio\tools\ohpm\bin;D:\DevEco Studio\sdk\default\openharmony\toolchains;' + $env:Path 依赖解析 + HAP 构建(模拟器架构)flutter pub gethvigorw --mode module -p module=entry@default -p product=default ` -p requiredDeviceType=phone -p TARGET_PLATFORM=ohos-x64 assembleHap
真机安装验证(hdc 命令行):
hdc install -r ohos\entry\build\default\outputs\default\entry-default-signed.haphdc shell aa start -a EntryAbility -b com.example.hm_webview_demohdc shell hilog -x | Select-String 'hm_webview_demo|ReferenceError'
十一、写在最后
这个 Demo 从一启动就崩,一路做到“杂志级”成品,验证了三件事:
-
这套 Demo 从一启动就崩,一直做到"杂志级"成品,验证了三件事。鸿蒙上跑 Flutter 混合开发完全可行:WebView 由 ArkWeb 内核驱动,webview_flutter_ohos 插件把 API 差异抹平了大半,H5 资产能直接复用。双向通信则是混合开发的核心资产——请求-响应配对、单通道分发、二次编码回填这套组合搭好之后,Token、设备信息、Toast、文件选择这些能力桥接只是"填空题",H5 页面几乎能做到原生能做的一切。
-
没有图片素材,全靠代码写,同样能做出高级感:把配色收一收,字号拉开对比,圆角统一起来,Demo 的水准就够上文章封面。真要再往下走,比较自然的路子是三条:H5Container 单独抽成 package 发出去,Bridge 协议补上方法版本号,再借 HmBridge.events 这条流把深色模式联动打通。这几样都不用另起炉灶,顺着现有架构就能长出来。
如果这篇文章对你有帮助,欢迎在评论区交流鸿蒙 Flutter 适配中遇到的问题。