跨端的边界:当我在鸿蒙上按下录音按钮

0 阅读7分钟

image.png

凌晨两点。

鸿蒙真机。微信里打开我自己的小程序,点语音录入,按住按钮,说了一句"我妈生日五月二十号"。

松手。

屏幕上没动静。

我以为是网络,刷新。再试一次。还是没动静。

然后我做了一件所有前端都做过的事——打开微信开发者工具,用模拟器复现。模拟器上一切正常:录音、识别、解析、入库,一条龙丝滑得像广告片。

回到鸿蒙真机。死的。

那一刻我明白了一件事:跨端框架解决的,是 UI 能不能显示出来。至于它能不能用——跨端框架从来没承诺过。

先交代背景,不然后面的坑没意义。

我做的是一个记日子的小程序:用户说一句话——"我老婆生日农历八月十五,提前一周提醒我"——AI 把这句话变成结构化字段,存库、定时推送微信订阅消息。

语音这条链路,看着短:

录音(前端) → 上传(base64) → ASR(腾讯云) → LLM 解析(后端) → 回传 JSON

五个箭头。我一度以为这是整个项目里最不会出问题的一段。毕竟微信官方的 RecorderManager 就摆在那里,文档写得清清楚楚,format 选 mp3 就完了。

人总是在最不设防的地方挨最重的一拳。

我以为的跨端是这样的:

// 看上去多干净
const recorder = uni.getRecorderManager()
recorder.start({
  format: 'mp3',
  sampleRate: 16000,
  numberOfChannels: 1
})
recorder.onStop((res) => {
  uploadToServer(res.tempFilePath)
})

iOS 能跑。安卓能跑。微信开发者工具能跑。

然后我拿到了一台鸿蒙真机。

onStop 不触发。

不是报错。不是 fail 回调。不是任何你能抓得住的东西。就是——不触发。你 stop() 了,录音管理器安静地接过这个指令,然后什么也不说。

你查文档。文档说这个 API 是支持的。 你查 issue。issue 里有人在问同样的问题,下面挂着三条"同问"。 你查微信的更新日志。日志里没有"鸿蒙适配"这一栏——也没有"鸿蒙不适配"这一栏。它就是没这一栏。

没有文档的沉默,比报错可怕一百倍。报错至少承认问题存在。

花了一晚上我才接受一个事实:在鸿蒙上,mp3 这条路走不通。

不是格式的问题。是整条流程——从 start 到 onStop 到 tempFilePath——在鸿蒙微信里是断的。你以为你在调一个 API,其实你在调一个声明了但没实现的占位符。

接受这件事花的时间,比写代码长。

人总是不愿意相信自己的工具是坏的。你会先怀疑自己的代码,再怀疑自己的真机,再怀疑用户的设备,最后才怀疑那个叫"跨端"的承诺。

这个顺序是反的。

既然 mp3 走不通,就换一条路。

微信的 RecorderManager 除了文件模式,还有一个被文档一笔带过的东西——PCM 帧模式。你不让它生成文件了,让它每录一小段就把原始音频帧通过 onFrameRecorded 回调给你。

recorder.start({
  format: 'PCM',           // 改这里
  sampleRate: 16000,
  numberOfChannels: 1,
  frameSize: 4              // 每 ~4KB 触发一次 onFrameRecorded
})

// 自己接帧
const pcmFrames = []
recorder.onFrameRecorded((res) => {
  if (res && res.frameBuffer) {
    pcmFrames.push(res.frameBuffer)
  }
})

帧模式绕过了 onStop。你不再依赖那个不会触发的回调,录音时长由你自己掐表,用户一点停止——你手里已经有一堆 ArrayBuffer 了。

但新问题来了:你手里的是裸 PCM。ASR 服务不认。

ASR 要的是 WAV、MP3、AAC 这种带容器格式的音频。裸 PCM 是一串 16bit 整数,没有采样率信息,没有声道信息,没有长度信息——ASR 拿到这堆字节,不知道该怎么播放它。

所以你得自己给它加一顶帽子。

WAV 格式说起来也不复杂。一个 44 字节的头,说明你这段音频是 16kHz、单声道、16bit、有多长。头后面跟 PCM 数据。

代码不长,但你得每个字节都写对:

const wrapPcmToWav = (pcmBytes, sampleRate = 16000, channels = 1, bits = 16) => {
  const dataLen = pcmBytes.byteLength
  const buffer = new ArrayBuffer(44 + dataLen)
  const view = new DataView(buffer)

  // RIFF 标识
  writeString(view, 0, 'RIFF')
  view.setUint32(4, 36 + dataLen, true)
  writeString(view, 8, 'WAVE')

  // fmt 子块
  writeString(view, 12, 'fmt ')
  view.setUint32(16, 16, true)          // fmt 块长度
  view.setUint16(20, 1, true)           // PCM 格式
  view.setUint16(22, channels, true)
  view.setUint32(24, sampleRate, true)
  view.setUint32(28, sampleRate * channels * bits / 8, true)  // 字节率
  view.setUint16(32, channels * bits / 8, true)               // 块对齐
  view.setUint16(34, bits, true)

  // data 子块
  writeString(view, 36, 'data')
  view.setUint32(40, dataLen, true)

  // PCM 数据本体
  new Uint8Array(buffer, 44).set(pcmBytes)
  return buffer
}

写这段的时候我查了三份不同的 WAV 规范文档。三份文档的字段偏移是一致的——它们必须一致,否则 WAV 就不是 WAV 了。

这件事很讽刺。一个九十年代就定死的文件格式,比 2025 年的跨端框架更可靠。

把 WAV 二进制转成 base64,走 JSON 上传。

到这里本来应该收尾了。但鸿蒙又伸出了一只手。

鸿蒙版微信开发者工具里,console.log 不是静默的——它会把日志内容在前端页面上渲染成浮层。

你写了一堆 console.log('[录音] PCM 总字节:', pcmBytes.byteLength) 方便调试,上线之后,鸿蒙用户打开你的小程序,会看到一行灰色的字悬浮在录音按钮上方,写着 [录音] PCM 总字节: 38400

这不是 bug。这是"特性"。

处理也简单,但要先知道它存在:

const __DEV__ = (() => {
  try {
    const info = uni.getAccountInfoSync()
    return info.miniProgram.envVersion !== 'release'
  } catch (e) {
    return false
  }
})()

const debugLog = (...args) => { if (__DEV__) console.log(...args) }

正式版里所有 debugLog 都是哑巴。鸿蒙用户耳根清净。

发现这个问题的时候我想起一句话——看起来整齐的东西,不一定能用console.log 是最整齐的 API 之一。整齐到你不会去怀疑它。

鸿蒙的坑讲完了。我把代码整理成一张 if-else 的表:

平台录音格式停止回调上传方式日志
iOSmp3onStop 触发文件路径正常
安卓mp3onStop 触发文件路径正常
鸿蒙PCM 帧拼 WAV不依赖 onStopbase64必须静音

你看这张表。前两行和第三行之间,有一条肉眼可见的裂缝。

跨端框架假装这条裂缝不存在。

到这里技术上的事讲完了。但我想多说一段。

AI 解析那一段本来也想展开写——Prompt 工程、few-shot、置信度兜底、Redis 缓存去重。这些在上一篇文章里写过,不重复。真正值得在这里说的是一件事

AI 解析这条线,在鸿蒙和 iOS 上跑得一模一样。因为它跑在后端。

越靠近服务器,跨端越真实。 越靠近硬件,跨端越虚假。

这不是技术选型的问题,是物理定律

服务器上的 Java 代码,不管请求来自 iOS、安卓、鸿蒙、H5、甚至某个我没见过的鸿蒙 Next,只要它能发出 HTTPS,Java 看到的就是一模一样的字节。Spring Boot 不在乎请求从哪个操作系统来——它根本不需要在乎

但前端不一样。前端贴在操作系统脸上。操作系统打个喷嚏,前端就得重写一版。麦克风怎么采样、权限怎么申请、文件系统怎么读、日志怎么显示——每一个都是操作系统的私人定制。

跨端框架解决的是 UI 层的私人定制。这件事它做得很好。 但越过 UI 往下一层——能力层——它就开始含糊其辞。

含糊到你上线之前不会发现,上线之后在某台鸿蒙真机上才会发现。

回到开头那个凌晨两点。

现在我知道那个"点下去没反应"的录音按钮背后发生了什么。不是按钮错了,不是网络断了,不是我的代码写错了。

是 onStop 没有触发。 是 tempFilePath 永远拿不到。 是整条文件流程在这台设备上不存在。 是跨端框架没告诉我它不存在。

解决方案最后有了,90 行代码。但那 90 行代码不是价值本身——价值是我知道了跨端的边界在哪里

跨端是一种约定。不是一种保证。

UI 层的约定基本牢靠。能力层的约定是撒谎的——不是框架故意,是物理上做不到。

下次选型的时候,我会先问自己一个问题:这个功能靠不靠硬件?

靠硬件,准备写三套。 不靠硬件,一套就够。

——

这就是我在鸿蒙上按下那个录音按钮学到的东西。代价是一个凌晨和 90 行兼容代码。

不便宜。但比上线后被用户发现便宜。

最后的成品:

横版.png