前言

朗读这件事在「诗境」里只有 164 行代码,却要处理三类问题:引擎什么时候可用、一段话什么时候真正念完、三个页面怎么知道「现在是不是在念」。答案分别落在启动页的串行初始化、requestId 过滤加 type === 1 判定、以及一个共享布尔键配 300ms 轮询上。

alt text

引擎只创建一次

export class TtsService {
  private static instance: TtsService | null = null;
  private engine: textToSpeech.TextToSpeechEngine | null = null;
  private isReady: boolean = false;
  private requestCounter: number = 0;
  private currentRequestId: string = '';

五个字段划清了职责:engine 是原生引擎句柄,isReady 是「能不能用」的开关,后两个是请求序号与「当前正在播的那一个」。单例是必要的 —— 引擎的创建和销毁都有成本,一个应用只需要一个输出通道。

创建参数全部指向离线中文:

  async init(context: common.UIAbilityContext): Promise<void> {
    // context 参数保留兼容 SplashPage 调用;CoreSpeechKit 不需要上下文
    this.isReady = false;
    const extraParam: Record<string, Object> = {
      'style': 'interaction-broadcast'
    };
    // online=1 表示离线模式;person=13 为聆小珊女声(系统预置)
    const chineseParams: textToSpeech.CreateEngineParams = {
      language: 'zh-CN',
      person: 13,
      online: 1,
      extraParams: extraParam
    };
    try {
      this.engine = await textToSpeech.createEngine(chineseParams);
      if (this.engine !== null) {
        this.engine.setListener(this.createListener());
      }
      this.isReady = true;
      hilog.info(DOMAIN, TAG, 'TTS engine ready: zh-CN person=13');
      await this.logAvailableVoices();
    } catch (e) {
      const err: BusinessError = e as BusinessError;
      this.isReady = false;
      hilog.error(DOMAIN, TAG, 'TTS init failed: code=%{public}d msg=%{public}s', err.code, err.message);
    }
  }

online: 1 是关键参数:走离线引擎,不依赖网络,元服务在飞行模式下也能朗读。「111 首小学生必背诗」这个场景里,离线是必须的 —— 用户可能在没网的地铁上打开它。person: 13 是系统预置的「聆小珊」女声,style: 'interaction-broadcast' 是播报风格。

context 参数保留了但没使用。注释写明了原因:CoreSpeechKit 的引擎创建不需要上下文。函数签名留着它,是为了让 SplashPage 的调用点不用改 —— 三个服务 load / init / init 都用同一个 ctx 变量传进去,写法整齐。

init 开头的 this.isReady = false 是重置。如果 init 被调第二次(当前没有,但接口允许),上一次的 isReady = true 不能延续到新的引擎就绪之前。

失败只有一条路:catch 里把 isReady 置回 false,打日志,不抛出。整段代码里没有任何「重试」。朗读是增强功能,引擎起不来时应用照样能看诗、收藏、打卡 —— 让它把启动流程打断才是最坏的结果。

logAvailableVoices 只打一行日志:

      const voices: textToSpeech.VoiceInfo[] = await textToSpeech.listVoices(voicesQuery);
      hilog.info(DOMAIN, TAG, 'available voices: %{public}d', voices.length);

它的作用不是功能,而是排障线索:设备上到底装了哪些离线音色、person: 13 是否真的存在。查询失败也只 warn,不影响 isReady。

release() 定义了但项目里没有调用点:

  release(): void {
    if (this.engine !== null) {
      this.engine.shutdown();
      this.engine = null;
    }
    this.isReady = false;
    AppStorage.setOrCreate(TTS_SPEAKING_KEY, false);
  }

引擎跟着进程走,EntryAbility 没有在 onDestroy 里调它。对一个前台使用时长的元服务,这个取舍是合理的 —— 每次退出就 shutdown 再重建,用户下次点朗读要多等一次引擎冷启动。

一次朗读的完整生命周期

  /** 朗读文本,返回是否成功触发 */
  speak(text: string): boolean {
    if (!this.isReady || this.engine === null) {
      hilog.warn(DOMAIN, TAG, 'TTS not ready, skip: %{public}s', text);
      return false;
    }
    const trimmed: string = text.trim();
    if (trimmed.length === 0) {
      return false;
    }
    // 先停止当前朗读,避免叠加
    this.engine.stop();
    AppStorage.setOrCreate(TTS_SPEAKING_KEY, false);

    this.requestCounter = this.requestCounter + 1;
    const requestId: string = `tts_${this.requestCounter}`;
    this.currentRequestId = requestId;
    const extraParam: Record<string, Object> = {
      'speed': 1,
      'volume': 2,
      'pitch': 1,
      'languageContext': 'zh-CN',
      'playType': 1
    };
    const speakParams: textToSpeech.SpeakParams = {
      requestId: requestId,
      extraParams: extraParam
    };
    try {
      this.engine.speak(trimmed, speakParams);
      return true;
    } catch (e) {
      const err: BusinessError = e as BusinessError;
      hilog.error(DOMAIN, TAG, 'speak failed: code=%{public}d msg=%{public}s', err.code, err.message);
      AppStorage.setOrCreate(TTS_SPEAKING_KEY, false);
      return false;
    }
  }

四层防线,逐层往下:

isReady 与 engine 双判。理论上两者同步,但 release() 会把 engine 置 null 而不动 isReady 之外的东西 —— 多判一次不亏。

trim() 后判空。诗词正文不会为空,但详情页会把「译文」「注释」也送给同一个入口,某些数据缺译文时是空串。空串送进引擎会得到一次没有声音的播报,UI 却亮起「正在朗读」,看起来像卡住了。

「先 stop() 再 speak()」。引擎不会自动打断上一段,不 stop 就是两段音频叠在一起念。同时把共享状态置 false —— 这一行是为监听器准备的:stop() 会触发 onStop 回调,但回调是异步的,可能在新的 onStart 之后才到,那一瞬间状态会错乱。主动先置 false,让「旧的结束」和「新的开始」之间有一个明确的低电平。

最后一个 try/catch 把同步异常转成 false 返回值。speak 的返回类型是 boolean 而不是 Promise<void>,因为它只回答一个问题:「这次请求发出去了吗」。发出去了不代表念完了,念完是监听器的事。

requestCounter 自增后拼成 tts_${n}。这个 id 是整套机制的核心 —— 引擎的所有回调都会带回 requestId,服务靠它判断「这个回调说的是不是我最后一次请求」。

alt text

requestId 过滤与 type === 1

  private createListener(): textToSpeech.SpeakListener {
    const self: TtsService = this;
    const listener: textToSpeech.SpeakListener = {
      onStart: (requestId: string, response: textToSpeech.StartResponse): void => {
        if (requestId === self.currentRequestId) {
          AppStorage.setOrCreate(TTS_SPEAKING_KEY, true);
        }
      },
      onComplete: (requestId: string, response: textToSpeech.CompleteResponse): void => {
        // type=1 表示播报真正结束(type=0 仅合成结束)
        if (requestId === self.currentRequestId && response.type === 1) {
          AppStorage.setOrCreate(TTS_SPEAKING_KEY, false);
        }
      },
      onStop: (requestId: string, response: textToSpeech.StopResponse): void => {
        if (requestId === self.currentRequestId) {
          AppStorage.setOrCreate(TTS_SPEAKING_KEY, false);
        }
      },
      onError: (requestId: string, errorCode: number, errorMessage: string): void => {
        hilog.error(DOMAIN, TAG, 'onError req=%{public}s code=%{public}d msg=%{public}s',
          requestId, errorCode, errorMessage);
        if (requestId === self.currentRequestId) {
          AppStorage.setOrCreate(TTS_SPEAKING_KEY, false);
        }
      }
    };
    return listener;
  }

const self: TtsService = this 是对象字面量里的经典写法。四个回调都是箭头函数,this 本来就已经绑定到 createListener 的调用者,self 是多余的 —— 但它让回调体里「访问的是服务实例而不是 listener 对象」这件事变得更明确,也避免了以后有人把某个回调改成 function 声明时的静默错误。

四个回调里有三个只看 requestId,唯独 onComplete 多了一个条件:

        if (requestId === self.currentRequestId && response.type === 1) {

CompleteResponse.type 区分「合成完成」和「播放完成」:0 是文本转音频结束,1 是音频真正播完。一段五言绝句的合成可能几百毫秒就结束了,播放要好几秒。如果只认 onComplete 而不看 type,状态会在开口的瞬间就翻回「不在朗读」,喇叭图标刚亮就灭,剩下的几秒音频在没有任何视觉反馈的情况下放完。

这一条判断没有任何注释之外的文档依据,只能靠 type 的取值含义。项目把它写成了行内注释,因为这是整段代码里最容易被人「简化掉」的一行 —— 看起来 onComplete 就够用了。

onStart 与 onStop 会成对出现:speak() 里先 stop() 旧的,旧请求的 onStop 会回来,此时 currentRequestId 已经换成新的,if 不成立,旧请求的停止不会污染新请求的状态。这正是过滤的意义。如果没有这个判断,点第二首诗时旧请求的 onStop 一到,界面立刻显示「未在朗读」,而新的一遍其实正在念。

状态只有一个布尔键

/** 全局 TTS 朗读状态键,UI 通过 @StorageLink 自动响应 */
export const TTS_SPEAKING_KEY: string = 'ttsSpeaking';

注释里写的是 @StorageLink,但项目里没有任何组件用 @StorageLink 绑定这个键 —— 三个消费页面全部用 @Local 加轮询。注释描述的是这个键的设计意图(它确实是一个可被响应式绑定观察的 AppStorage 键),实际实现选了另一条路。

AppStorage 在这里充当的是一个跨组件信箱:写的一方是 TtsService(四个回调 + stop + release),读的一方是页面。用 @StorageLink 绑定的问题是双向的,页面也能写这个键,而朗读状态的所有权应该只在服务里。轮询是只读的,所有权清晰。

  private pollSpeakingState(): void {
    const timerId: number = setInterval(() => {
      const cur: boolean = AppStorage.get<boolean>(TTS_SPEAKING_KEY) as boolean;
      this.isSpeaking = cur;
      if (!cur) {
        clearInterval(timerId);
      }
    }, 300);
  }

这段在详情页和刷卡页各有一份,除了本地字段名(speaking 与 isSpeaking)之外完全一致。三个特点:

自终止。没有「无限轮询」的风险 —— 一读到 false 就 clearInterval,定时器自己把自己停掉。所以调用点不需要保存 timerId,也不需要 aboutToDisappear 里清理。刷卡页的 aboutToDisappear 之所以能终止它,是靠 TtsService.stop() 把状态置 false,让下一轮(最迟 300ms 后)读到 false 自行退出。

只轮询「结束」,不轮询「开始」。调用点都是先乐观地把本地状态置 true 再启动轮询:

    const ok: boolean = TtsService.getInstance().speak(poem.content);
    if (!ok) {
      this.getUIContext().getPromptAction().showToast({ message: '语音引擎暂不可用', duration: 1500 });
    } else {
      this.isSpeaking = true;
      this.pollSpeakingState();
    }

speak() 返回 true 就认为「开始念了」,界面立刻切到朗读中。轮询只负责发现「念完了」。

300ms 是一个折中值。更短会白耗电(一轮两三次 AppStorage.get 不算什么,但定时器本身有调度开销),更长会让「念完后图标还亮着」的滞后变得明显。一首五言绝句念完约 5 秒,300ms 的滞后是 6%,肉眼不可辨。

这个顺序里有一个客观存在的窗口:如果引擎的 onStart 迟到超过 300ms(首次合成、引擎刚创建时最可能),第一轮轮询会读到 AppStorage 里那个「speak() 主动置的 false」,于是判定「不在朗读」,把 isSpeaking 改回 false 并停掉定时器 —— 声音随后照常出来,但图标已经灭了,也不会再有任何轮询把它点亮。

项目接受了这个窗口,因为 300ms 相对引擎启动的常态耗时有足够余量,而消除它需要引入「等 onStart 再启动轮询」的状态机:页面得知道 onStart 什么时候到,那就得让服务对外暴露回调或再引入一个 starting 状态。为一次可能在低端机上才发生的闪烁,加一个跨层的状态机不划算。

alt text

三个消费点的三种用法

位置触发方式有独立状态失败提示离页处理
PoemDetailPage按钮切换整篇@Local speaking无不 stop
PoemCardLearnPage按钮朗读当前卡@Local isSpeakingToaststop()
HomeTabPage每日一诗旁的小按钮无无—

详情页是「切换」语义:

  async onPlayWhole(): Promise<void> {
    if (this.poem === null) {
      return;
    }
    if (this.speaking) {
      TtsService.getInstance().stop();
      this.speaking = false;
      return;
    }
    const ok: boolean = TtsService.getInstance().speak(this.poem.content);
    if (ok) {
      this.speaking = true;
      // 轮询同步朗读状态(与 AppStorage 保持一致)
      this.pollSpeakingState();
    }
  }

按钮在「朗读」和「停止」之间切换:speaking 为真时点它就是停。stop() 之后手动把 speaking 置 false,不等轮询 —— 这是唯一一个状态由页面主动写的地方,因为它要立刻反馈。

详情页进页面时还会做一次状态清零:

    AppStorage.setOrCreate(TTS_SPEAKING_KEY, false);
    const curVal: boolean = AppStorage.get<boolean>(TTS_SPEAKING_KEY) as boolean;
    this.speaking = curVal;

setOrCreate 是无条件写入,所以紧接着的 get 一定拿到 false,两行合起来等价于 this.speaking = false。写成「先写再读」的形态,作用是顺带确保这个全局键在 AppStorage 里存在(首次运行时不存在的键 get 会返回 undefined,as boolean 之后是个假值,虽然不影响这里的逻辑,但类型上不干净)。

代价是:如果上一个页面正在朗读、用户点进详情页,这一行会把全局状态清成 false,而引擎其实还在念。此时详情页的按钮显示「朗读」,点下去会先 stop() 旧的再念新的,功能上不会出问题,只是按钮文案与真实状态不符。

刷卡页是「重播」语义 —— 同一个按钮点几次都会重新念当前这首,因为 speak() 内部会先 stop()。翻页必须静音:

  private nextPoem(): void {
    if (this.currentIndex < this.poems.length - 1) {
      TtsService.getInstance().stop();

上一首的音频念到下一张卡上,是这个交互里最违和的错误。nextPoem / prevPoem / retryAll 三个翻页入口都以 stop() 开头,不管当前是否在朗读 —— 多调一次 stop() 的代价是一次空回调,漏调一次的代价是两首诗串在一起。

  aboutToDisappear(): void {
    StorageService.getInstance().removeChangeListener(this.onStoreChange);
    TtsService.getInstance().stop();
  }

刷卡页离页时停读,详情页不停。这个差别是有意的:刷卡页的上下文是「当前这张卡」,离开页面就没有「当前」了;详情页的上下文是「这一首诗」,退出页面后把剩下的两句念完不算错。两种做法都能自圆其说,项目按页面语义分别选了。

首页只调 speak,不管状态:

                TtsService.getInstance().speak(this.dailyPoem!.content);

它没有播放/停止按钮,只有一个小图标按钮,点一次念一遍。没有状态意味着连点两次会「停掉再重念」,而不是叠加 —— speak() 内部的 stop() 兜住了这一点。这是「不引入状态」的前提:中断语义已经由服务保证了。

初始化顺序为什么不能换

启动页三个 await 的顺序是词库 → 朗读引擎 → 存储:

      this.progressMsg = '正在加载课本诗...';
      await PoemRepository.getInstance().load(ctx);
      this.progressMsg = '正在准备朗读引擎...';
      await TtsService.getInstance().init(ctx);
      this.progressMsg = '正在读取学习进度...';
      await StorageService.getInstance().init(ctx);

词库必须最先,因为后面两个都可能在 UI 里立刻用到诗词数据;StorageService.init 里的 updateStreak 会 persist,触发 persistListener 刷新卡片,而卡片数据构建要读词库(getDailyPoem)—— 词库没加载完就刷卡片,卡片上会是「每日一诗加载中…」。

朗读引擎排在存储之前,是把「最慢且最不影响首屏」的一步放在前面:引擎创建要几百毫秒,放最后会让用户在加载进度已经到「读取学习进度」之后还多等一段;放在中间,进度文案与耗时同步变化,观感更自然。

三步之间用一个 @Local progressMsg 串起来,每步之前改一次文案。这不是严格的进度条(三步耗时并不均匀),但它让等待有了具体的解释 —— 用户看到「正在准备朗读引擎」时不会怀疑应用卡死了。

整段包在一个 try/catch 里,任何一步抛错都落到「加载失败,请重试」。三个服务各自的失败处理策略不同:词库加载失败会抛(后续一切依赖它),引擎初始化失败不抛(自降级),存储初始化基本不会失败。所以这个 catch 实际上主要接的是词库的异常。

一句话的取舍

朗读功能的可靠性来自三件事的组合:requestId 过滤让旧请求的回调无法污染新请求的状态,response.type === 1 把「合成完」和「念完了」区分开,共享布尔键加 300ms 自终止轮询让三个页面用同一套机制读状态而不必各自持有引擎引用。代价集中在一处 —— 状态由轮询而非推送送达,所以「念完」到「图标复位」之间最多有 300ms 的滞后,且首轮存在一个理论上会被误判为「未开始」的窗口。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐