MD 阅读 Android 版 · 语音导出(MP3)与菜单/关于页
Android 版落地保存语音 / MP3 导出(未引入 NDK、零新增权限),新增设置弹窗,并把「关于」页与鸿蒙逐项对齐;导出产物经帧级扫描与 ffmpeg 解码双重复核,确认可播放。
版本:1.1.4 (versionCode 1001004) 包名:
com.bzm.mdreader.android验证机:华为 Mate 30 Pro (LIO-AN00) / Android 12 / SDK 31 / 1176×2400 验证日期:2026-09-24 验证方式:USB 真机 + adb 自动化操作 + 离线帧级/解码级复核
一、结论
本轮三项交付全部真机验收通过:
- 保存语音 / MP3 导出 —— 从零落地,未引入 NDK、未新增任何权限;产物经四路独立复核确认为合法可播放 MP3。
- 菜单「设置」 —— 新增入口与设置弹窗,置于分隔线以下、与「关于」并列。
- 「关于」页 —— 与鸿蒙逐项对齐(品牌区 / 定位语 / 可点击署名 / 版本历史 / 蓝色关闭)。
附带完成:把 Android 侧成型的图形渲染层(高度算法与 CSS)同步回鸿蒙版,并修掉同类隐患。
二、MP3 导出:技术路线与关键决策
2.1 原计划被推翻
初版设想是「synthesizeToFile → WAV 拼接 → LAME NDK 编码」。落地前实测发现该路线代价过高,改用更省的方案:
| 候选方案 | 判定 | 原因 |
|---|---|---|
| LAME + NDK(原计划) | ❌ 弃 | 需装 NDK 与 CMake,构建链变长、产物体积翻倍;本机此前未装 |
系统 MediaCodec | ❌ 弃 | 只能输出 AAC/M4A,出不了 MP3 |
| WebView + 与鸿蒙同一份 lamejs | ✅ 采用 | 零新增依赖、零新增权限;编码器与鸿蒙完全同一份源码,音质与参数天然一致 |
2.2 链路
逐句 synthesizeToFile ──▶ WAV(16kHz/mono/16bit) ──▶ 解析 RIFF/WAVE 定位 data 区
▲ │
│ 在途句数上限 4(流控) ▼
└──────────── 分块 PCM ──▶ WebView 内 lamejs ──▶ MP3 字节流 ──▶ SAF 流式落盘
- 编码器运行在 WebView:
assets/mp3enc.html加载assets/lamejs.js(由鸿蒙libs/lamejs.esm.js转换export→window.*得到),
Kotlin 侧通过 evaluateJavascript 下发 base64 PCM、取回 base64 MP3 分片。
- 格式锁定在首句:以第一句真实 WAV 头为准确定采样率/声道,后续句格式不一致则跳过(防引擎中途换格式导致音调错乱)。
- 取消即收尾:点「停止保存」不丢数据 —— 已编码部分
flush尾部后写完整 MP3 帧流,产出的是可正常播放的短文件,不是损坏文件。
2.3 改动文件
| 文件 | 说明 |
|---|---|
core/TtsPlayer.kt | 新增「仅合成不发声」模式(synthesizeToFile 流水线 + 在途句数流控) |
core/Mp3EncoderBridge.kt | 新增。WebView 编码桥(主线程建 View、后台线程阻塞式调用) |
core/AudioExporter.kt | 新增。WAV 解析 + 分块编码 + 流式落盘 + 取消收尾 |
assets/mp3enc.html | 新增。编码页面(PCM 进、MP3 出) |
assets/lamejs.js | 新增。与鸿蒙同源的 LAME 编码器 |
ui/ReadCard.kt | 新增保存三态 UI(进度条 / 百分比 / 字节数 / 第 N/M 句) |
MainActivity.kt | 接线 SAF 保存选择器、导出编排、进度回调;保存期间禁用播放 |
ui/SettingsDialog.kt | 新增。设置弹窗 |
ui/AboutDialog.kt | 新增。关于弹窗 + 版本历史抽取(与鸿蒙同算法) |
ui/ReaderScreen.kt | 菜单补「设置」项与分组分隔线 |
assets/diag.html | 删除。图形白屏排查期的诊断页,已无引用,避免随包发布 |
三、真机验收清单(全部通过)
| # | 验收项 | 结果 | 证据 |
|---|---|---|---|
| 1 | 保存入口 | ✅ | 点「保存语音」拉起系统保存选择器(CREATE_DOCUMENT typ=audio/mpeg),默认文件名 demo.mp3 |
| 2 | 保存态 UI | ✅ | 按钮转「保存中」、进度条 + 59.43% + 2.7 MB + 「第 104/175 句」,位置行显「保存位置:1」 |
| 3 | 保存期间禁播放 | ✅ | UI dump 显示「开始」按钮 enabled="false"、透明度 0.4 |
| 4 | 时间行 | ✅ | 保存中显示 00:00 / 11:45(预估总时长,与鸿蒙同一 4.5 字/秒公式) |
| 5 | 保存完成 | ✅ | 面板复位、弹「已保存为 MP3:<文件名>」 |
| 6 | 取消保存 | ✅ | 点「停止保存」→ cancel requested → stopped (synth=true) → released;面板复位、可再次保存 |
| 7 | 取消语义 | ✅ | 中断产出 1,562,688 字节部分文件,帧流完整、可播放(见第四节) |
| 8 | 重复导出确定性 | ✅ | 同一文档两次全量导出字节数完全一致(均 6,363,360 字节) |
| 9 | 零权限 | ✅ | Manifest 仍只有 2 条普通级权限(前台服务×2,供朗读播放用),无 INTERNET、无存储权限 |
| 10 | 菜单「设置」 | ✅ | 菜单六项:源码(预览) / 打开文件 / 分享 Markdown / 语音朗读 / ─── / 设置 / 关于 |
| 11 | 设置弹窗 | ✅ | 默认项 4 条 + 「更多设置项将在后续版本开放」+「知道了」 |
| 12 | 关于弹窗 | ✅ | M 徽标 + v1.1.4 徽章 + 定位语 + 可点击署名 + 版本历史 + 蓝色关闭 |
| 13 | 版本号运行时读取 | ✅ | 弹窗显示 v1.1.4 == versionName == 鸿蒙 app.json5(杜绝硬编码漂移) |
| 14 | 署名跳官网 | ✅ | 点击后由系统浏览器打开官网(com.huawei.browser),截图确认落地页为志睿源官网 |
| 15 | 关于关闭 | ✅ | 关闭后 版本历史 节点消失(UI dump 命中数 0) |
| 16 | 回归:源码切换 | ✅ | 切源码态正常显示 Markdown 原文 |
| 17 | 回归:分享 | ✅ | 拉起系统分享面板(HwChooserActivity) |
四、MP3 产物独立复核(离线,四路交叉)
| 复核手段 | 结果 |
|---|---|
| 逐帧扫描(自写解析器) | 22,095 帧、坏字节 0、解析终点 恰好等于文件长度(6,363,360) |
| 参数 | MPEG2 Layer III / 16000 Hz / 64 kbps / 单声道(与请求一致) |
| ffmpeg 解码 | 00:13:15.42,解出 12,726,720 采样点(= 795.42 s × 16000 Hz,完全吻合) |
| 音量统计 | mean −21.5 dB / max −2.6 dB → 真实语音(静音会是约 −91 dB) |
| 静音间隙检测 | 全篇 无 > 2 s 断流(只有句间自然停顿) |
| Android MediaStore 自解析 | 时长 795420 ms —— 与逐帧计算毫秒级完全一致 |
| 取消产物 | 5,426 帧 / 0 坏字节 / 03:15.34 / mean −21.4 dB,同样完整可播 |
字节熵 7.335 / 8,40 个 20 秒分块全部均匀 → 全程真实语音、首尾无缺损。
五、平台差异与取舍(需知情)
| 项 | 鸿蒙 | Android | 处置 |
|---|---|---|---|
| 导出母带采样率 | 固定 16000 Hz | 跟随系统语音引擎(实测华为 hiAI = 16000 Hz) | 代码内置 9 档白名单(8000~48000),不在白名单则明确报错而非静默出错 |
| 保存中状态行 | 显示「准备就绪」 | 同 | 保持与鸿蒙一致(未擅自改成「保存中」) |
| 取消时提示 | 「已取消保存」+「已停止,部分语音已保存」双 Toast | 同 | 与鸿蒙行为一致 |
| 编码器 | lamejs(ArkTS) | lamejs(WebView) | 同一份源码,参数与音质一致 |
六、图形渲染层同步回鸿蒙版
Android 侧本轮成型的通用改进已同步回鸿蒙 rawfile/graph.html:
- 确定性高度算法 ——
h = min(自然宽, 可用宽) × vbH/vbW + padding,等价浏览器max-width:100% + height:auto,与测量时机无关; - viewBox 归一 +
fitViewBoxToContent(); body.fullscreen限定height:100%/overflow:hidden(原全局写法会导致普通流内容整块不绘制 —— 图形白屏的根因);- 全屏容器
position:fixed四边归零(百分比高度链在哑页面里已断,改 fixed 才居中)。
接纳下限同步放宽:MermaidView.ets 80 → 24、SvgView.ets 60 → 24(与 Android GRAPH_MIN_H 对齐)——
这正是 Android 上「7.8 时间线真实高 79 被下限 80 拒掉 → 底部留白 181px」的同一隐患。
两版唯一保留差异:htmlLabels —— Android 必须 false(WebView 下 true 会让整个 SVG 不绘制),鸿蒙保持 true(ArkWeb 无此问题,保留富文本标签能力)。已在两版文件内互相注明「改一处勿误同步另一处」。
静态核对已完成(鸿蒙机本轮未接入,无法编译验收):getInlineHeight() / renderGraph(code, fitWidth) / renderSvg(code, fitWidth) / renderAny 接口均保留,
鸿蒙 MermaidView.ets 的轮询调用与 Index.ets 全屏调用签名逐一对齐,无破坏性风险。
七、权限与合规
- 未新增任何权限;Manifest 仍为零 INTERNET、零存储、零电话等敏感权限。
- 导出走 SAF(系统保存选择器),用户自选保存位置,应用不需要任何存储权限。
- MP3 编码在本地 WebView 内完成,不联网;「零权限 · 零网络 · 零收集」卖点未被破坏。
八、下一步
- Release 签名打包 → 华为 AGC 上架(功能已齐,建议先做一轮长文档导出内存复核)
- 菜单「隐藏系统栏 / 显示系统栏」(对齐鸿蒙菜单第 3 项)
- Android 工程尚未纳管版本控制(
C:\bzm\android\mdreader-lite无 git)——建议尽快git init并首次提交,与鸿蒙版对齐 - 鸿蒙侧 graph.html 同步后需真机回归一次图形高度与全屏(本轮只做了静态核对)