HarmonyOS开发——鸿蒙 App 内嵌 WebView 的 H5 语音输入适配实战

场景:一个用纯 Web 技术栈写的 H5 语音助手(按住说话 → 实时语音识别 → 返回文字),被宿主 App 用 <web-view> 内嵌。iOS / 安卓一切正常,唯独鸿蒙(HarmonyOS NEXT)端「按住说话」毫无反应。本文记录从现象到根因的逐层剥茧过程,以及最终落地的最小改动方案。

注:文中公司/产品名、域名、IP、AppID、内部包名等敏感信息均已脱敏,用占位或泛指替代。

一、背景与约束

  • 语音助手是一个独立的 H5 单页应用(下文称「助手页」),内部实现了两条语音链路:
    1. 原生桥接路径:在宿主 App 的 WebView 里,通过 document.title = 'NATIVE_BRIDGE://...' 的 hack 调用原生录音能力;
    2. 纯 Web 降级路径:getUserMedia 采集麦克风 → Web Audio 降采样出 PCM → 通过 WebSocket 推给腾讯云实时 ASR → 回流识别文本。
  • 宿主 App 用 uni-app 开发,鸿蒙端再套一层 ArkTS 壳(基于 ArkWeb,Chromium 内核)加载助手页。
  • 关键约束:对非鸿蒙端必须零行为变化。所以所有改动都要精准锁定「只在鸿蒙 WebView 里生效」,不能一刀切影响 iOS/安卓。

理想很丰满,但鸿蒙端一按就"哑火"。真正的活儿,全在排查上。

排障全景决策图

下面是这次逐层剥茧的完整路径 —— 每一层都由一个真机现象触发,验证一个假设,再落到一个具体修复:

flowchart TD Start([鸿蒙端「按住说话」无反应]) --> C1{图片能传但语音提示<br/>“需要到 App 中”?} C1 -- 是 --> R1[isSecureContext === false] R1 --> F1[部署到 HTTPS 环境] F1 --> C2 C1 -- 否/已上 HTTPS --> C2{日志出现<br/>startVoiceRecord → Unsupported?} C2 -- 是 --> R2[鸿蒙被误判为有原生桥接<br/>UA 被 setCustomUserAgent 覆写] R2 --> F2[改用结构化信号判定:<br/>__uniapp_x_ 命中即鸿蒙<br/>plus 存在则非鸿蒙] F2 --> C3 C2 -- 否/改完仍无反应 --> C3{JS 产物哈希<br/>index-[hash].js 未变?} C3 -- 是 --> R3[源码 ≠ 部署包<br/>根本没重新构建] R3 --> F3[重建 + 部署 + 清 ArkWeb 缓存] F3 --> C4 C3 -- 否/哈希已变 --> C4{弹了录音界面但<br/>不弹权限 / 无二进制帧?} C4 -- 是 --> R4[getUserMedia 排在 await 之后<br/>丢失 user-activation] R4 --> F4[手势同步窗口内先发起 getUserMedia<br/>+ 建 AudioContext + resume] F4 --> Done([✅ 弹权限 → 有音频帧 → ASR 回流 → 出文字]) C4 -- 否 --> Done C2 -- 日志显示 sign ok/WS 已连 --> C4

阅读顺序即真实排障顺序:安全上下文 → 容器误判 → 部署包不一致 → 用户手势丢失。前两层解决“进不进得去”,后两层解决“进得去但跑不通”。

二、第一层:图片能传,语音不能 —— 安全上下文

先确认了一个反直觉现象:上传图片正常,点语音却提示"当前为 http 调试页,需要到 App 中"。

原因藏在 getUserMedia 的一条硬规则里:

navigator.mediaDevices.getUserMedia 只在 安全上下文(Secure Context) 下可用,即 HTTPS 或 localhost。

而助手页当时指向的是 http://192.168.x.x:5174/ 这样的局域网调试地址。window.isSecureContext === false,语音被直接拦下;图片走的是 <input type="file"> + fetch 直传对象存储,不受这条限制,所以照常工作。

结论:要上真机验证语音,助手页必须部署到 HTTPS 环境。这一步只是"入场券",后面还有硬仗。

三、第二层:按住无反应 —— 原生桥接"挂死"

部署到 HTTPS、装好 HAP 后,鸿蒙端「按住说话」依然没反应。借助 ArkWeb 的 onConsole(它会把 H5 的 console 转发成原生日志),第一次抓到了真机日志:

[NativeBridge] call: startVoiceRecord
Unsupported method: startVoiceRecord

真相浮出水面:

  • 助手页误判自己是"普通 App WebView",走了原生桥接路径,调用 startVoiceRecord;
  • 而鸿蒙那层打过补丁的 WebView 只实现了 closeWebview,其余方法一律回 Unsupported method;
  • 于是 H5 侧那个 await 的 Promise 永远等不到结果 —— 挂死,表现就是"点了没反应"。

问题变成:为什么鸿蒙 WebView 里会被误判成"有原生桥接"?

四、第三层:UA 判定被 setCustomUserAgent 覆写

助手页原本靠 navigator.userAgent 里匹配 ArkWeb|OpenHarmony|HarmonyOS 来识别鸿蒙。但宿主的 WebView 配置里有一行:

.setCustomUserAgent(<uni-app 标准 UA>)

它把 UA 覆写成了 uni-app 的标准 UA,里面根本不含 ArkWeb / OpenHarmony / HarmonyOS 任何关键字。UA 判定当场失效,于是鸿蒙被漏判、错走了原生桥接。

改用结构化信号判定容器,而不是靠 UA 字符串:

export function isHarmonyWebview(): boolean {
  if (typeof window === 'undefined') return false
  // ① 鸿蒙 ArkWeb <web-view> 独有注入的 JS 代理,最强确定性信号
  if (typeof (window as any).__uniapp_x_ !== 'undefined') return true
  // ② 安卓/iOS:存在 plus 即 Html5Plus 容器,保留原生桥接
  if (typeof window.plus !== 'undefined') return false
  // ③ 兜底:UA
  if (typeof navigator !== 'undefined') {
    return /ArkWeb|OpenHarmony|HarmonyOS/i.test(navigator.userAgent || '')
  }
  return false
}

判定顺序很讲究:__uniapp_x_ 命中即鸿蒙(它是 ArkWeb <web-view> 独独会注入的 Java 代理),plus 存在则判为非鸿蒙(安卓/iOS 走 Html5Plus),UA 只当兜底。鸿蒙走 Web 降级、其余端维持原生桥接,互不干扰。

附带确认:宿主打过补丁的 WebView.ets 里,onPermissionRequest 已把 AUDIO_CAPTURE → MICROPHONE、VIDEO_CAPTURE → CAMERA 做了映射,经 requestPermissionsFromUser 后 request.grant()。也就是说 ArkWeb 是会转发 H5 麦克风授权的,方案 A 的前提成立。oh-package 重装会覆盖这个补丁,记得重跑打补丁脚本,并在 module.json5 声明 MICROPHONE/CAMERA 权限。

五、第四层:改了个寂寞 —— 部署产物和源码对不上

修完 UA 判定,再按,还是没反应。翻第二次日志,一个细节救命了:

source: https://<prod-cdn>/assets/index-C0YubvDt.js

这个 JS 文件的 内容哈希 index-C0YubvDt.js 和第一次一模一样。Vite 的产物名带内容哈希,源码变了哈希必变 —— 哈希没变,说明助手页压根没重新构建/部署,我的修复根本没进包。

重新构建部署后,index-xxxx.js 的哈希果然变了,日志也往前走了一步:[ASR] sign ok、WebSocket 建连成功。教训:排查前端问题时,先用产物哈希确认"你改的源码 = 真机跑的包",否则一切推断都建立在沙上。

六、第五层:能连不能识别 —— getUserMedia 丢了用户手势

语音链路"通了",但按住说话后依然出不了文字。日志显示:整段录音从头到尾,只有松手时那一条 WebSocketSend data is String(即 {type:'end'}),没有任何二进制音频帧、也没有识别回包。

用户随即更正了现象:"弹出了录音界面,但没调起麦克风、没弹出权限框。" —— 这一句直接把根因钉死了。

回头看助手页的降级路径代码,顺序是这样的:

// ❌ 修复前
const startWebLiveAsr = async () => {
  await ensureAsrClient().start(...)            // 先 await:走 /asr/sign 网络请求 + 建 WebSocket
  const stream = await navigator.mediaDevices.getUserMedia(...)  // 之后才取麦克风
  // ...
}

问题出在浏览器的 user-activation(用户手势)机制:

getUserMedia 触发系统权限弹窗,依赖"按下"这次手势仍处于有效期内。

而代码里在 getUserMedia 之前先 await 了一个网络请求(签名)加 WebSocket 建连。这一串 await 把那次触摸的手势窗口消耗殆尽。ArkWeb 只在手势有效期内发起的 getUserMedia 才会触发 onPermissionRequest → 弹系统授权框;手势一过期,getUserMedia 既不成功也不弹,浮层就卡在那里干等。

修法:把所有依赖手势的动作,提到函数内"第一个 await 之前"同步发起。

// ✅ 修复后
const startWebLiveAsr = async (session: number) => {
  usingWebLiveAsr = true
  receivedPcmBytes = 0

  if (!navigator.mediaDevices?.getUserMedia) throw new Error('当前环境不支持录音')
  const AudioCtx = window.AudioContext || (window as any).webkitAudioContext
  if (!AudioCtx) throw new Error('当前环境不支持音频处理')

  // 仍在手势窗口内:并发发起「ASR 连接(签名 + WS)」与「getUserMedia」,二者互不阻塞
  const asrReady = ensureAsrClient().start(handlers, { voiceFormat: 1 })
  asrReady.catch(() => undefined)              // 不 await,让签名/连库在后台跑
  const micPromise = navigator.mediaDevices.getUserMedia({
    audio: { channelCount: 1, echoCancellation: true, noiseSuppression: true },
  })
  // 同处手势窗口内立即建 AudioContext,尽量以 running 起
  audioContext = new AudioCtx()

  const stream = await micPromise              // 先等麦克风(此时权限框已在手势内弹出)
  if (audioContext.state === 'suspended') await audioContext.resume().catch(() => undefined)

  // 接上采音频链路
  mediaSourceNode = audioContext.createMediaStreamSource(stream)
  scriptProcessor = audioContext.createScriptProcessor(4096, 1, 1)
  scriptProcessor.onaudioprocess = (ev) => {
    if (!ensureAsrClient().isConnected()) return   // 未连上则丢弃,损失极小
    const pcm = floatToPcm16k(ev.inputBuffer.getChannelData(0), audioContext!.sampleRate)
    receivedPcmBytes += pcm.byteLength
    ensureAsrClient().sendAudio(pcm)
  }
  // ...connect...

  await asrReady                               // 音频链路就绪后,再确认 ASR 连上才进录音态
  // ...起计时、显示录音 UI...
}

要点:

  1. getUserMedia 是函数里第一个被 await 的东西(前面只允许同步的能力校验与 new AudioContext()),保住 user-activation,权限框才会弹。
  2. ASR 连接(签名 + WS)与取麦克风并发:.start() 不 await,让网络请求和"用户看权限框 + 点允许"的时间重叠,等用户开口时 ASR 通常已连上,开头几乎不丢音频。
  3. AudioContext 也提前到手势窗口内创建,并以 running 起;万一仍 suspended,补一句 resume()。这是"有权限却零 PCM"的隐性坑——挂起的 AudioContext 会让 ScriptProcessor.onaudioprocess 根本不回调。

改完,鸿蒙端:按下 → 弹出麦克风授权 → 允许 → 持续发出二进制音频帧 → [ASR] wss message 回流 → 出文字。语音输入贯通。

七、复盘与经验

  1. 安全上下文是底线:getUserMedia 在非 HTTPS 且非 localhost 下一定被拦,图片上传则不受此限,别被"图片能用"误导。
  2. 容器判定别只信 UA:setCustomUserAgent 会覆写 UA。结构化信号更可靠——鸿蒙 ArkWeb <web-view> 独注入 __uniapp_x_,安卓/iOS 有 plus。
  3. 先确认"源码 = 部署包"再推断:用 Vite 产物的内容哈希(index-[hash].js)判断是否真的重新构建部署;哈希不变,改了也白改。
  4. 依赖手势的媒体 API 必须在手势同步窗口内发起:getUserMedia / AudioContext 等前面不能插任何 await(网络、其它异步都会吃掉 user-activation)。判断录音是否真采到数据,要看有没有二进制帧发出,而不是只看 getUserMedia 是否 resolve。
  5. 利用被忽略的日志通道:ArkWeb 的 onConsole 会把 H5 的 console 转成原生日志,真机排障时是低成本、非侵入的利器——比临时加 UI toast 干净得多。
  6. 跨端适配要"精准锁定目标端":本次所有分支都以"仅鸿蒙 WebView 生效"为前提,iOS/安卓保持原生桥接,做到非目标端零行为变化。

本文涉及的具体域名、IP、包名、内部实现细节均已脱敏,方法论与代码思路可直接复用于同类"H5 内嵌鸿蒙 ArkWeb 的媒体能力适配"场景。

posted on 2026-10-12 20:53  码间留白  阅读(8)  评论(0)    收藏  举报

导航