这是我开发的第三个鸿蒙 App,已经上架华为应用市场。前两个是git仓库查看工具和批量加水印工具,这一个,是把文档变成声音的听书软件「BookVoice」。三个 App 做完,我最想说的一句话是:真正让你脱层皮的不是语言,是"系统思维"——而这套思维,第三个 App 时已经在复用了。

这个 App 是干嘛的? 名字叫BookVoice,一句话:把 TXT / MD / PDF / DOCX / EPUB 等文档变成声音,用耳朵听书。目标用户很具体——通勤路上想把积压的电子书读完的上班族、做家务时想"解放双眼"继续学习的人、出差途中想听长文但不想把私人文档交给在线工具的阅读爱好者。

听起来很常规?其实听书 App 有个天然的技术难题:朗读不能丢字、不能跳段。你写前端、写工具类 App 时,从没想过"逐句读一段文本"能成为核心难点——直到你真正去实现。

先看它有哪些功能,后面讲技术点,你就知道每个功能"硬"在哪:

  • 📄 多格式即导即读:TXT / MD / PDF / DOCX / EPUB 一键导入,PDF 自动逐页提取文本并智能分段,TXT 保持原文分段并合并被拆散的句子
  • 朗读跟随高亮:当前朗读段落高亮 + 当前朗读字逐字金色跟随(这个后面展开讲)
  • ⏯️ 专业播放控制:精确到句的快进快退、0.5x~2.0x 倍速、章节目录一键跳转
  • 睡眠计时:30/60/90/120 分钟定时停止,到点自动停播
  • 🔄 后台持续朗读:退出页面、锁屏、切 App 不中断,系统控制中心播控
  • 📊 听书数据统计:今日 / 累计时长 + 聆听目标进度环 + 已听 / 未听书单;聆听时长解锁 10 级勋章
  • ☁️ 云端同步:华为账号登录,听书进度云端同步,换机无缝接续
  • 🔒 隐私保障:文档全程本地处理、不上传、不存储

下面挑三个花心思最多的技术点展开——它们正好是前两个 App 都没碰到过的领域:文本解析语音播放时序,还有逐字位置

先看选型差异:一张表说清楚
维度 传统 Web / Android HarmonyOS(ArkTS + ArkUI + Stage)
语言 JS / Kotlin 等 ArkTS = TypeScript 严格子集,no-any / no-untyped-obj-literals 一票禁止
UI DOM / XML 布局 ArkUI 声明式,@Component + @State 状态驱动
生命周期 Activity / 页面栈 Stage 模型:UIAbility + AbilityStage,导航用 NavPathStack
语音 第三方 SDK @kit.CoreSpeechKit 文本转语音,callback 为主,需手工 Promise 包装
自建后端 AGC 端云一体化:CloudDB / 匿名登录一条龙

ArkTS 严格模式已经是老朋友了——前两个 App 就领教过。但真正让我意外的是语音合成 API 的"回调地狱"downloadVoice 这种接口 SDK 只给 callback 重载,没有 Promise,得自己包装;进度事件载荷还是个字符串,得剥掉非数字字符解析成 0~100。

三个核心技术点

① 文本解析:PDF 不是"读出来"就完了

PDF 逐页提取文本后,最烦人的是硬换行拆词——"坐在"两个字可能被拆成两行,直接朗读就会断句奇怪。自研 TextParser 干三件事:按句末标点(。!?)识别句子边界、识别章节标题(「第一章」「Chapter 1」甚至裸阿拉伯数字「1」)、按空行合并自然段落。这样解析出的文本才能按"段落"为单位朗读,而不是一个字符一个字符地念。

// 核心:把被硬换行拆散的文本按句末标点重新合并成自然段落
function parseParagraphs(lines: string[]): string[] {
  const paras: string[] = [];
  let cur = '';
  for (const t of lines) {
    if (isChapterTitle(t)) {      // 章节标题独立成段
      if (cur) { paras.push(cur); cur = ''; }
      paras.push(t);
    } else if (endsSentence(t)) { // 句末标点收尾 → 段落结束
      cur += t; paras.push(cur); cur = '';
    } else {
      cur += t;                   // 续行合并
    }
  }
  if (cur) { paras.push(cur); }
  return paras;
}

踩过的坑:PDF 段落编号(独立成行的「1」「2」)会被并进下一段成为首行首字。最后给 isChapterTitle 加了"整行仅 1~3 位数字即视为标题"的规则,与既有章节识别对称,问题根治。

② TTS 播放时序:让朗读不丢字、不跳段

听书 App 的命门是换段时机。最初按每秒 tick 反查段落推进,结果段末 1~2 字被掐断、段首 1~3 字被跳过——用户听着听着就少几个字。根因是估算播放时长与真实 TTS 播报有偏差。

改法是让真实 TTS 播报完成信号驱动换段onComplete 置完成标记,配合估算结束点 next >= cap 双重校验,再加超时兜底;同时用 EMA 指数加权校准播报速度,让进度条与语音始终同步。模拟器上 TTS 每段真实触发 onComplete 但耗时只有 0.6~1.3 秒(远低于估算),一度被误判为"播完"连续跳段——加了个可信区间过滤(realSec ∈ [0.5×, 2.5×]×估算),失真耗时不参与校准。

// 核心:换段由「估算结束点已到」+「完成信号或超时兜底」共同驱动
if (next >= cap && (completionArrived || stallTicks >= stallLimit)) {
  advanceParagraph();
  completionArrived = false;
  stallTicks = 0;
}

这段工程解决的是听书最基本的问题:不丢字、不跳段。它不花哨,但很基础——听书嘛,听着顺耳最重要。

③ 朗读跟随高亮:逐字位置,是「算」出来的

这个功能源自一个很朴素的需求:朗读到哪,字就亮到哪。用户看着正文,声音走到哪个字,那个字上就有一个金色小框跟着移动。竞品 iOS 应用有,鸿蒙上没有现成方案——因为两堵墙摆在那:

  • Text 组件没有逐字位置 API,拿不到「第 N 个字符在屏幕上的 (x, y)」;
  • TTS 回调里也没有逐字进度,不知道现在读到第几个字。

只能自己算。位置用 measureText 逐字测宽,再按和正文完全一致的断行规则(中文逐字断、英文按空格断词、首行缩进一致)做贪心换行,模拟出每个字的坐标:

// 核心:把"第 N 个字符"翻译成屏幕坐标(规则与正文渲染完全一致)
function locateChar(text: string, n: number): { x: number; y: number } {
  let line = 0, x = 0;
  for (let i = 0; i < n; i++) {
    const w = measureText(text[i]);           // 逐字测宽
    if (x + w > lineWidth) { line++; x = 0; } // 贪心换行
    x += w;
  }
  return { x, y: line * lineHeight };
}

进度同样没有回调——固定语速估算会漂移,于是用真实播报耗时反推实测语速(EMA 校准 + 前导系数补偿段内停顿),让金框既不落后也不超前地跟住当前朗读的字。这大概是这个 App 里最「教科书上没有、只能自己造」的一段工程:没有逐字 API 的鸿蒙上,逐字高亮就是这么硬算出来的。

做得好的 & 还能优化的

做得好的:文本解析 + TTS 时序双管齐下,朗读稳定不丢字;逐字跟随高亮让"看字听书"有了跟读感;AVSession 后台播控 + 书架迷你条让"边做家务边听"体验完整;AGC 端云一体化让换机接续听书进度几乎零后端成本。

还能优化的:PDF 复杂版式(多栏、表格、页眉页脚)的解析准确率还有提升空间;大文档首次解析耗时较长,希望进一步优化分阶段解析策略;听书时长统计目前只做了按日结算,周 / 月 / 年维度统计在 TODO 里。

给同行的话

一个技术路线的价值,在第二个、第三个 App 时才真正显现。 git仓库查看、加水印、听书,三个完全不同的 App,但共用同一套 ArkTS 方法论:严格模式逼出的类型意识、Stage 模型的生命周期管理、AGC 端云一体化的"免后端"打法。每做一个,都在往这套方法论里加新的一块。

如果你也在手机上读了大量电子书、长文,想试试"用耳朵读完",华为应用市场搜「BookVoice」就能用。

转载请注明文章来源:

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐