一
凌晨两点。
鸿蒙真机。微信里打开我自己的小程序,点语音录入,按住按钮,说了一句"我妈生日五月二十号"。
松手。
屏幕上没动静。
我以为是网络,刷新。再试一次。还是没动静。
然后我做了一件所有前端都做过的事——打开微信开发者工具,用模拟器复现。模拟器上一切正常:录音、识别、解析、入库,一条龙丝滑得像广告片。
回到鸿蒙真机。死的。
那一刻我明白了一件事:跨端框架解决的,是 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 的表:
| 平台 | 录音格式 | 停止回调 | 上传方式 | 日志 |
|---|---|---|---|---|
| iOS | mp3 | onStop 触发 | 文件路径 | 正常 |
| 安卓 | mp3 | onStop 触发 | 文件路径 | 正常 |
| 鸿蒙 | PCM 帧拼 WAV | 不依赖 onStop | base64 | 必须静音 |
你看这张表。前两行和第三行之间,有一条肉眼可见的裂缝。
跨端框架假装这条裂缝不存在。
九
到这里技术上的事讲完了。但我想多说一段。
AI 解析那一段本来也想展开写——Prompt 工程、few-shot、置信度兜底、Redis 缓存去重。这些在上一篇文章里写过,不重复。真正值得在这里说的是一件事:
AI 解析这条线,在鸿蒙和 iOS 上跑得一模一样。因为它跑在后端。
越靠近服务器,跨端越真实。 越靠近硬件,跨端越虚假。
这不是技术选型的问题,是物理定律。
服务器上的 Java 代码,不管请求来自 iOS、安卓、鸿蒙、H5、甚至某个我没见过的鸿蒙 Next,只要它能发出 HTTPS,Java 看到的就是一模一样的字节。Spring Boot 不在乎请求从哪个操作系统来——它根本不需要在乎。
但前端不一样。前端贴在操作系统脸上。操作系统打个喷嚏,前端就得重写一版。麦克风怎么采样、权限怎么申请、文件系统怎么读、日志怎么显示——每一个都是操作系统的私人定制。
跨端框架解决的是 UI 层的私人定制。这件事它做得很好。 但越过 UI 往下一层——能力层——它就开始含糊其辞。
含糊到你上线之前不会发现,上线之后在某台鸿蒙真机上才会发现。
十
回到开头那个凌晨两点。
现在我知道那个"点下去没反应"的录音按钮背后发生了什么。不是按钮错了,不是网络断了,不是我的代码写错了。
是 onStop 没有触发。 是 tempFilePath 永远拿不到。 是整条文件流程在这台设备上不存在。 是跨端框架没告诉我它不存在。
解决方案最后有了,90 行代码。但那 90 行代码不是价值本身——价值是我知道了跨端的边界在哪里。
跨端是一种约定。不是一种保证。
UI 层的约定基本牢靠。能力层的约定是撒谎的——不是框架故意,是物理上做不到。
下次选型的时候,我会先问自己一个问题:这个功能靠不靠硬件?
靠硬件,准备写三套。 不靠硬件,一套就够。
——
这就是我在鸿蒙上按下那个录音按钮学到的东西。代价是一个凌晨和 90 行兼容代码。
不便宜。但比上线后被用户发现便宜。
最后的成品: