HarmonyOS 6实战ArkWeb同层渲染与双端通信—AI Action富媒体卡片的用户体验优化一个让人纠结的细节一、问题回顾Web卡片的“画风”为什么不对我们之前是怎么做的解决方案的思路二、技术方案同层渲染是什么2.1 概念解析2.2 工作原理2.3 为什么选这个方案三、实战把AI Action返回卡片里的Web组件换成原生3.1 改造前后对比3.2 H5页面改造3.3 原生搜索组件3.4 原生跳转按钮组件3.5 NodeController实现3.6 主页面集成五、技术要点总结5.1 同层渲染的必要条件5.2 事件传递机制5.3 多组件同层渲染5.4 双向通信总结一个让人纠结的细节之前的文章里我们用Copilot SDK的Action机制实现了一个富媒体卡片它支持返回多种数据类型其中页包含web卡片例如一些网页预览组件卡片里包含各种跳转入用户点击就能拉起三方应用。功能跑通后我兴冲冲地拿给设计朋友看。她看了一眼说了一句让我印象深刻的话“这个卡片看着还行但总觉得哪里不太对。”我追问哪里不对她指着屏幕说“你看这个卡片格式是网页默认样式下面这些按钮也是网页模拟的。而咱们App里其他地方用的都是鸿蒙原生组件——圆角更大、阴影更柔和、点击反馈更细腻。这个卡片插在对话流里就像一件衣服打了个补丁有点违和。”说实话她不说我还没太注意。但仔细一看确实如此。Web组件渲染的输入框、按钮和ArkTS原生组件确实有肉眼可见的差异——字体渲染、圆角处理、点击态反馈都不一样。这种“违和感”在技术圈有个专门的叫法视觉断层。用户在一个App里同时看到原生风格和Web风格的内容会下意识觉得“这个卡片不是App的一部分”从而降低信任感。怎么办我们能不能把AI卡片里的Web组件换成原生ArkUI组件让卡片和App其他部分长得一模一样答案是可以的用ArkWeb同层渲染技术。一、问题回顾Web卡片的“画风”为什么不对我们之前是怎么做的回顾一下之前的文章内容我们通过Action机制返回了一个Web卡片。核心逻辑是在JumpCardAction的render方法里返回一个Web组件Web组件加载一个HTML页面页面里是各种跳转入用户点击链接触发onLoadIntercept回调交给原生处理这个方案的优点是简单、灵活——HTML/CSS随便改不用发版。缺点也很明显Web组件渲染的UI和ArkTS原生UI有天然的视觉差异。具体差异体现在维度Web组件ArkTS原生组件字体渲染依赖系统WebView引擎依赖ArkUI框架与系统设置联动圆角处理CSS border-radius原生圆角支持更细腻的动画点击反馈CSS :active伪类延迟感原生涟漪效果跟手感强阴影效果box-shadow性能开销原生阴影支持动态模糊列表滚动WebView滚动跨端一致性差原生滚动丝滑跟手这些问题不是Web技术本身的缺陷而是Web渲染引擎和原生渲染引擎是两套独立的体系。想让它们完全一致几乎不可能。所以后面我们使用AI Action 生成Arkts的富媒体卡片再跳转到活动页做了一轮小小的优化。但是Arkts打开H5页的这种体验也不是最优的。我们做了一轮小范围用户调研让10个人同时使用原生UI和Web卡片问他们“你觉得这两个界面有什么不同”反馈很有意思7个人说“右边那个Web卡片感觉有点‘糊’”其实是字体渲染差异5个人说“按钮按下去没反应”其实是点击反馈不明显3个人说“这个卡片像是弹窗广告”视觉断层带来的不信任感这些问题如果用户不说你可能永远不知道。但它们确实在影响体验。解决方案的思路既然问题出在“Web组件渲染”上最直接的方案就是把Web组件里的内容换成原生ArkUI组件。但难点在于AI返回的卡片是动态的、由AI决定要不要展示的。我们不能提前写好原生组件得在运行时动态创建。这就需要用到鸿蒙提供的**同层渲染Native Embed**能力——把原生组件直接渲染到WebView的指定位置让它在视觉上和Web内容融为一体。二、技术方案同层渲染是什么2.1 概念解析同层渲染是HarmonyOS ArkWeb提供的一种高级渲染技术。它的核心能力是允许将原生ArkUI组件直接渲染到WebView的指定位置实现原生组件与H5内容的深度融合。你可以把它理解为在Web页面里挖一个“坑”然后在这个坑里种一棵“原生组件树”。这棵树和周围的Web内容在同一层级显示视觉上完全融合但交互上拥有原生组件的全部能力。这个技术的几个关键特性视觉融合原生组件与H5内容在同一层级显示没有“补丁感”事件传递触摸事件可以在原生组件和H5之间正确传递性能优化原生组件享有更好的渲染性能滚动更流畅节点复用支持组件的创建、更新和销毁生命周期管理同层渲染特别适合以下场景场景说明典型例子复杂表单Web实现逻辑原生实现交互AI卡片里的搜索框、选择器媒体播放Web控制UI原生实现播放器视频卡片、音频控制地图组件Web展示位置原生实现交互路线预览、位置选择自定义视图Web布局原生实现高性能视图心率曲线图、运动轨迹我们这次要优化的AI卡片就属于“复杂表单”和“自定义视图”的混合场景。2.2 工作原理同层渲染的核心机制是“离屏节点动态上下树”。用一张图来解释关键步骤解析H5占位前端用embed标签标记原生组件的插入位置生命周期回调Web组件解析到embed触发onNativeEmbedLifecycleChange节点创建ArkTS侧创建NodeController在makeNode()中构建原生组件视觉融合原生组件直接渲染到WebView的指定位置与周围内容无缝衔接2.3 为什么选这个方案对比几种可能的优化方案方案优点缺点适用性继续用Web优化样式改动小无法解决根本差异❌ 治标不治本完全用ArkTS重写二级页体验完美动态内容需要发版❌ 灵活性差同层渲染体验完美动态灵活实现复杂度高✅ 最优解同层渲染把“动态内容”和“原生体验”结合起来了。AI可以决定“什么时候展示卡片”卡片里的交互组件是原生的体验和App其他部分完全一致。三、实战把AI Action返回卡片里的Web组件换成原生理解了原理我们开始动手改造。目标是把第三篇文章里的Web跳转卡片改成同层渲染方案让搜索框、按钮等组件变成原生的。3.1 改造前后对比改造前Web卡片卡片整体由Web组件渲染搜索框是HTMLinput元素按钮是HTMLbutton元素点击反馈依赖CSS:active改造后同层渲染卡片卡片布局由Web控制标题、说明文字交互组件搜索框、跳转按钮替换为原生ArkUI组件原生组件通过embed占位符嵌入到Web内容中点击反馈是原生涟漪效果跟手感强3.2 H5页面改造首先改造之前跳转卡片的HTML把交互组件替换成embed占位符。!-- entry/src/main/resources/rawfile/jump_card_embed.html --!DOCTYPEhtmlhtmlheadmetacharsetUTF-8metanameviewportcontentwidthdevice-width, initial-scale1.0title快捷跳转面板/titlestyle*{margin:0;padding:0;box-sizing:border-box;}body{font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,sans-serif;background:#f5f5f5;padding:16px;}.card{background:white;border-radius:16px;padding:20px;box-shadow:0 2px 8pxrgba(0,0,0,0.06);}.title{font-size:18px;font-weight:600;margin-bottom:8px;color:#1f2f3a;}.subtitle{font-size:14px;color:#7f8c8d;margin-bottom:20px;border-left:3px solid #10a37f;padding-left:12px;}.section-title{font-size:14px;font-weight:500;margin:16px 0 12px 0;color:#2c3e50;}.embed-placeholder{margin:8px 0;}.footer-note{margin-top:16px;font-size:12px;color:#95a5a6;text-align:center;padding-top:12px;border-top:1px solid #ecf0f1;}/style/headbodydivclasscarddivclasstitle✨ 快捷跳转面板/divdivclasssubtitle点击下方入口快速跳转到对应应用/div!-- 搜索框使用原生组件渲染 --divclasssection-title 快速搜索/divdivclassembed-placeholderembedidnativeSearchtypenative/searchwidth100%height48pxsrcsearch//div!-- 快捷跳转按钮使用原生组件渲染 --divclasssection-title 快捷入口/divdivclassembed-placeholderembedidnativeJumpButtonstypenative/jump-buttonswidth100%height200pxsrcbuttons//divdivclassfooter-note 点击任意入口即可跳转搜索框支持关键词检索/div/divscript// 监听embed加载完成事件window.addEventListener(load,function(){console.log(Page loaded, embeds ready);});// 提供JSBridge给原生侧调用可选functiononSearchResult(keyword){console.log(Search keyword: keyword);// 可以在这里触发跳转或搜索逻辑}/script/body/html关键改动搜索框从input改为embed idnativeSearch typenative/search跳转按钮组从一组a标签改为embed idnativeJumpButtons typenative/jump-buttonstype属性以native/开头这是同层渲染的约定3.3 原生搜索组件实现原生搜索框组件提供和App其他页面一致的输入体验。// entry/src/main/ets/components/NativeSearchComponent.ets import { ProductDataModel } from ../model/GoodsModel; import { webview } from kit.ArkWeb; Component export struct NativeSearchComponent { Prop params: { width: number, height: number, webController?: webview.WebviewController }; State searchValue: string ; private searchController: SearchController new SearchController(); // 搜索结果回调通过JSBridge通知H5 private onSearch(keyword: string): void { // 调用H5侧的JS函数 this.params.webController?.runJavaScript(onSearchResult(${keyword})); } build() { Column() { Search({ placeholder: 搜索应用或功能, value: this.searchValue, controller: this.searchController }) .width(this.params.width) .height(this.params.height) .backgroundColor(Color.White) .borderRadius(12) .onChange((value: string) { this.searchValue value; this.onSearch(value); }) .onSubmit(() { this.onSearch(this.searchValue); }) } } } Builder function nativeSearchBuilder(params: { width: number, height: number, webController?: webview.WebviewController }) { NativeSearchComponent({ params: params }) }3.4 原生跳转按钮组件实现原生按钮组包含之前Web卡片里的所有跳转入// entry/src/main/ets/components/NativeJumpButtonsComponent.ets import { JumpHandler } from ../common/JumpHandler; Component export struct NativeJumpButtonsComponent { Prop params: { width: number, height: number, onJump?: (url: string) void }; // 跳转入口数据 private jumpItems: Array{ icon: string, name: string, desc: string, url: string } [ { icon: , name: 原生页面, desc: 跳转到ArkTS原生页面, url: arkts://pages/origin }, { icon: ️, name: 打开地图, desc: 拉起地图应用, url: third-party://map }, { icon: ⚙️, name: WiFi设置, desc: 打开系统设置-WiFi页面, url: system://settings/wifi }, { icon: , name: 应用市场, desc: 跳转到应用市场详情页, url: market://detail }, { icon: , name: 跨设备拉起, desc: 拉起其他设备的应用, url: cross-device://launch } ]; build() { Column() { ForEach(this.jumpItems, (item: typeof this.jumpItems[0], index: number) { Button() { Row() { Text(item.icon) .fontSize(24) .margin({ right: 12 }) Column({ space: 4 }) { Text(item.name) .fontSize(16) .fontWeight(FontWeight.Medium) Text(item.desc) .fontSize(12) .fontColor(#7f8c8d) } .alignItems(HorizontalAlign.Start) Blank() Text(→) .fontSize(18) .fontColor(#95a5a6) } .width(100%) .padding({ left: 16, right: 16, top: 12, bottom: 12 }) } .width(100%) .height(56) .backgroundColor(#f8f9fa) .borderRadius(12) .margin({ bottom: 8 }) .onClick(() { this.params.onJump?.(item.url); }) }, (item: typeof this.jumpItems[0], index: number) ${item.url}_${index}) } .width(this.params.width) .height(this.params.height) } } Builder function nativeJumpButtonsBuilder(params: { width: number, height: number, onJump?: (url: string) void }) { NativeJumpButtonsComponent({ params: params }) }3.5 NodeController实现这是同层渲染的核心——NodeController负责管理原生组件的生命周期。// entry/src/main/ets/controllers/EmbedNodeController.ets import { BuilderNode, FrameNode, NodeController, NodeRenderType, wrapBuilder, UIContext } from kit.ArkUI; import { webview } from kit.ArkWeb; import { NativeSearchComponent, nativeSearchBuilder } from ../components/NativeSearchComponent; import { NativeJumpButtonsComponent, nativeJumpButtonsBuilder } from ../components/NativeJumpButtonsComponent; // 组件类型枚举 export enum EmbedComponentType { SEARCH native/search, JUMP_BUTTONS native/jump-buttons } // 组件参数接口 export interface EmbedComponentParams { width: number; height: number; webController?: webview.WebviewController; onJump?: (url: string) void; } // 渲染选项接口 export interface EmbedRenderOptions { surfaceId: string; type: string; renderType: NodeRenderType; embedId: string; width: number; height: number; webController?: webview.WebviewController; onJump?: (url: string) void; } export class EmbedNodeController extends NodeController { private rootNode: BuilderNode[EmbedComponentParams] | null null; private embedId: string ; private surfaceId: string ; private renderType: NodeRenderType NodeRenderType.RENDER_TYPE_DISPLAY; private componentType: string ; private componentWidth: number 0; private componentHeight: number 0; private webController?: webview.WebviewController; private onJump?: (url: string) void; /** * 设置渲染参数 */ setRenderOption(params: EmbedRenderOptions): void { this.surfaceId params.surfaceId; this.renderType params.renderType; this.embedId params.embedId; this.componentType params.type; this.componentWidth params.width; this.componentHeight params.height; this.webController params.webController; this.onJump params.onJump; } /** * 创建节点 - NodeController核心方法 */ makeNode(uiContext: UIContext): FrameNode | null { this.rootNode new BuilderNode(uiContext, { surfaceId: this.surfaceId, type: this.renderType }); // 根据组件类型构建不同的原生组件 if (this.componentType EmbedComponentType.SEARCH) { this.rootNode.build(wrapBuilder[EmbedComponentParams](nativeSearchBuilder), { width: this.componentWidth, height: this.componentHeight, webController: this.webController }); } else if (this.componentType EmbedComponentType.JUMP_BUTTONS) { this.rootNode.build(wrapBuilder[EmbedComponentParams](nativeJumpButtonsBuilder), { width: this.componentWidth, height: this.componentHeight, onJump: this.onJump }); } return this.rootNode?.getFrameNode() ?? null; } /** * 更新节点 */ updateNode(arg: EmbedComponentParams): void { this.rootNode?.update(arg); } /** * 获取embedId */ getEmbedId(): string { return this.embedId; } /** * 获取BuilderNode */ getBuilderNode(): BuilderNode[EmbedComponentParams] | null { return this.rootNode; } /** * 设置BuilderNode */ setBuilderNode(rootNode: BuilderNode[EmbedComponentParams] | null): void { this.rootNode rootNode; } /** * 传递触摸事件 */ postEvent(event: TouchEvent | undefined): boolean { return this.rootNode?.postTouchEvent(event) as boolean; } }3.6 主页面集成最后改造主页面启用同层渲染模式并注册生命周期回调。// entry/src/main/ets/pages/ChatPage.ets import { CopilotChat, CopilotController } from hw-agconnect/copilot; import { JumpHandler } from ../common/JumpHandler; import { EmbedNodeController, EmbedComponentType } from ../controllers/EmbedNodeController; import { webview } from kit.ArkWeb; import { NodeContainer } from kit.ArkUI; import { NativeEmbedStatus } from kit.ArkWeb; Entry ComponentV2 export struct ChatPage { controller: CopilotController new CopilotController({ llm: { baseUrl: https://api.deepseek.com/v1, apiKey: your-api-key, model: deepseek-chat }, systemPrompt: 你是「旅行回忆盲盒」的AI助手。当用户询问你能做什么、有哪些功能、或者想查看跳转入口时展示快捷跳转面板。 }); private jumpHandler: JumpHandler new JumpHandler(getContext(this)); private webController: webview.WebviewController new webview.WebviewController(); // 同层渲染相关 State componentIdArr: Arraystring []; private nodeControllerMap: Mapstring, EmbedNodeController new Map(); aboutToAppear(): void { // 监听跳转事件 AppStorage.link(jumpUrl, (newValue: string) { if (newValue) { this.jumpHandler.handleJump(newValue); AppStorage.setOrCreate(jumpUrl, ); } }); } build() { Stack() { // 同层渲染的原生组件容器 ForEach(this.componentIdArr, (componentId: string) { NodeContainer(this.nodeControllerMap.get(componentId)) }, (embedId: string) embedId) // AI聊天组件包含Web卡片 // 这里已经删掉我的业务代码 } } }// 隐藏的Web组件用于同层渲染 Web({ src: $rawfile(jump_card_embed.html), controller: this.webController }) .width(0) .height(0) .opacity(0) .enableNativeEmbedMode(true) // 启用同层渲染模式 .onNativeEmbedLifecycleChange((embed) { const componentId embed.info?.id?.toString() as string; if (embed.status NativeEmbedStatus.CREATE) { // 创建阶段初始化NodeController let nodeController new EmbedNodeController(); nodeController.setRenderOption({ surfaceId: embed.surfaceId as string, type: embed.info?.type as string, renderType: NodeRenderType.RENDER_TYPE_TEXTURE, embedId: embed.embedId as string, width: this.getUIContext().px2vp(embed.info?.width ?? 0), height: this.getUIContext().px2vp(embed.info?.height ?? 0), webController: this.webController, onJump: (url: string) { AppStorage.setOrCreate(jumpUrl, url); } }); nodeController.rebuild(); this.nodeControllerMap.set(componentId, nodeController); this.componentIdArr.push(componentId); } else if (embed.status NativeEmbedStatus.UPDATE) { // 更新阶段 let nodeController this.nodeControllerMap.get(componentId); nodeController?.updateNode({ width: this.getUIContext().px2vp(embed.info?.width ?? 0), height: this.getUIContext().px2vp(embed.info?.height ?? 0) }); nodeController?.rebuild(); } else if (embed.status NativeEmbedStatus.DESTROY) { // 销毁阶段 let nodeController this.nodeControllerMap.get(componentId); nodeController?.setBuilderNode(null); nodeController?.rebuild(); this.nodeControllerMap.delete(componentId); const index this.componentIdArr.indexOf(componentId); if (index -1) { this.componentIdArr.splice(index, 1); } } }) .onNativeEmbedGestureEvent((touch) { // 触摸事件传递 this.componentIdArr.forEach((componentId: string) { let nodeController this.nodeControllerMap.get(componentId); if (nodeController?.getEmbedId() touch.embedId) { nodeController?.postEvent(touch.touchEvent); } }); })关键配置enableNativeEmbedMode(true)必须启用同层渲染模式隐藏WebViewWeb组件本身不可见只用来触发embed解析和生命周期回调NodeRenderType.RENDER_TYPE_TEXTURE使用纹理渲染性能更好事件传递通过onNativeEmbedGestureEvent将触摸事件传递给原生组件五、技术要点总结5.1 同层渲染的必要条件条件说明代码位置embed标签H5页面必须包含embed标签type以native/开头HTMLenableNativeEmbedMode(true)Web组件启用同层渲染模式Web组件属性NodeController继承NodeController实现makeNode()ArkTSBuilderNode在makeNode()中创建BuilderNode构建原生组件NodeController生命周期回调处理CREATE/UPDATE/DESTROY三种状态onNativeEmbedLifecycleChange事件传递实现onNativeEmbedGestureEvent调用postEventWeb组件回调同层渲染中H5传过来的尺寸单位是pxArkUI用的是vp需要转换width: this.getUIContext().px2vp(embed.info?.width ?? 0), height: this.getUIContext().px2vp(embed.info?.height ?? 0)渲染类型说明适用场景RENDER_TYPE_DISPLAY直接渲染简单静态组件RENDER_TYPE_TEXTURE纹理渲染复杂交互组件性能更好5.2 事件传递机制同层渲染的事件传递是显式的WebView接收到触摸事件判断事件位置是否在某个embed区域内触发onNativeEmbedGestureEvent回调开发者调用对应NodeController的postEvent()方法原生组件响应事件这个机制保证了事件不会误传也支持多点触摸。// CREATE创建NodeController设置参数添加到Map if (embed.status NativeEmbedStatus.CREATE) { let controller new EmbedNodeController(); controller.setRenderOption({...}); controller.rebuild(); this.nodeControllerMap.set(id, controller); this.componentIdArr.push(id); } // UPDATE更新组件参数 else if (embed.status NativeEmbedStatus.UPDATE) { let controller this.nodeControllerMap.get(id); controller?.updateNode({...}); controller?.rebuild(); } // DESTROY清理资源 else if (embed.status NativeEmbedStatus.DESTROY) { let controller this.nodeControllerMap.get(id); controller?.setBuilderNode(null); controller?.rebuild(); this.nodeControllerMap.delete(id); }5.3 多组件同层渲染一个H5页面可以包含多个embed标签每个对应不同的原生组件。NodeController通过embedId区分Map存储所有实例。embedidsearchtypenative/searchwidth100%height48px/embedidbuttonstypenative/jump-buttonswidth100%height200px/embedidcharttypenative/heart-rate-chartwidth100%height150px/H5侧可以通过JS监听窗口变化动态调整embed的尺寸window.addEventListener(resize,(){constembeddocument.getElementById(nativeSearch);embed.setAttribute(width,window.innerWidth-32);});ArkTS侧会收到UPDATE回调更新组件尺寸。5.4 双向通信除了事件传递还可以通过runJavaScript实现ArkTS→H5的通信// ArkTS侧 this.webController.runJavaScript(updateSearchResult(${keyword})); // H5侧 function updateSearchResult(keyword) { console.log(Received from native:, keyword); }这比WebMessagePort更简单适合轻量级通信。同层渲染的最大优势是可以复用App里已有的原生组件。比如我们之前做的心率曲线图用Canvas绘制的现在可以直接嵌入到AI卡片里不需要在Web里重新实现一遍。embedidheartRateCharttypenative/heart-rate-chartwidth100%height150px/总结这一篇的改动从功能上看其实没有增加新能力——跳转还是那些跳转卡片还是那个卡片。但从体验上看提升是质的飞跃。一个App里最容易被用户感知的往往不是“功能多强”而是“细节多细”。搜索框有没有清除按钮按钮按下去有没有反馈字体和系统一不一致这些细节用户可能说不出来但感受得到。同层渲染让我们有能力把AIAction 的富媒体web卡片做得和App其他部分一模一样。AI返回的内容可以动态变化但交互体验始终是原生的。这就是我理解的“AI原生应用”——AI负责内容原生负责体验。