前言

一篇会议笔记需要连续听几分钟时,单独放一个播报按钮已经难以满足操作需要。用户可能希望暂停片刻、继续原来的内容,或者直接进入另一篇文章。如果应用自行拼装这些控制,就需要同时维护标题、文章列表、播放位置、面板显示与按钮状态,页面稍有切换,当前显示的文章也可能与正在播放的内容错开。

场景化语音服务提供了文字朗读控件,应用可以把已有文章交给控件,由它组织朗读面板和播放交互。应用仍然拥有文章内容,并且需要为每篇内容提供稳定标识。面板打开以后,播放状态会通过事件返回,因此页面还要区分文章是否在列表中、是否正在播放,以及面板当前有没有显示,不能让一个布尔值代替全部状态。

页面向系统朗读面板提供两篇中文内容。在 nova 14 的 HarmonyOS 7、API 26 Release 环境中,已经检查了暂停、继续、切换文章和停止释放。当前操作只在前台进行,初始化没有增加后台持续运行权限。

长文本朗读实战封面

一、朗读面板需要先取得当前窗口

打开完整工程,先检查应用入口的窗口创建方法。朗读控件需要依附当前应用窗口显示面板,因此入口在创建窗口阶段把窗口管理对象交给语音服务。这个动作发生在加载文章页面以前,页面发起朗读时就能找到所属窗口。如果只在页面中调用初始化,却没有设置窗口来源,后面即使文章内容齐全,也可能在初始化或显示阶段失败。

    WindowManager.setWindowStage(windowStage);

这行代码位于应用入口的窗口创建回调中。窗口对象由系统提供,朗读控件通过它找到承载面板的应用窗口。

窗口准备完成以后,语音服务使用当前页面所在的应用上下文初始化控件。当前配置采用离线方式,不开启后台持续运行,保留小型播放入口,并按一秒周期请求进度。这些设置对应页面中的前台阅读操作,后续可以通过进度回调观察当前文章的播放位置。

      await TextReader.init(context, { isVoiceBrandVisible: false, online: 1,
        keepBackgroundRunning: false, isMinibarNeeded: true, reportProgressPeriod: 1000 });
      this.initialized = true;
      TextReader.on('stateChange', this.state);
      TextReader.on('showPanel', this.panel);
      TextReader.on('readProgress', this.progress);

初始化成功以后,服务才把自身标记为已经就绪,并注册文章状态、面板显示与进度三类事件。这样可以避免初始化失败时继续调用播放方法,让后面的错误掩盖最先失败的位置。当前服务还会复用已经初始化的控件,所以多次打开朗读入口时,不会在同一次使用过程中重复注册相同监听。

这次 nova 14 前台运行没有新增后台持续运行权限,控件初始化成功。朗读入口也没有申请录音授权,因为它读取的是已有文字。如果需要离开前台后继续朗读,还要根据后台运行场景核对任务、权限与控件参数。

二、给两篇内容建立稳定的文章身份

控件初始化以后,还需要知道当前朗读哪些内容。页面从资源目录读取两份 UTF-8 文本,每份包含 371 个中文字符,分别描述城市公园游览与社区图书室整理活动。两篇内容有不同标题,可以用来检查朗读列表和切换后的状态。

应用把内容转换为朗读条目时,为每篇指定独立标识。第一篇使用 reading1,第二篇使用 reading2,标题和正文跟在各自条目中。标识决定回调和控制操作指向哪一篇文章,标题则帮助用户在面板里辨认内容。两篇标题相似或者以后被修改时,稳定标识仍然应该保持独立,不能把标题是否相同当作文章身份判断。

    const list: TextReader.ReadInfo[] = [
      { id: 'reading1', title: { text: '城市公园的游览路线', isClickable: false }, bodyInfo: first },
      { id: 'reading2', title: { text: '社区图书室的整理活动', isClickable: false }, bodyInfo: second }
    ];
    await TextReader.start(list, 'reading1');
    TextReader.showPanel();
    this.sink('readerQuery', JSON.stringify(TextReader.queryReadState()));

标题与正文通过不同字段传入,因此应用需要保证两者对应。当前第一篇标题为城市公园的游览路线,第二篇为社区图书室的整理活动,分别对应两份已读取的正文。标题对象还包含是否可以点击的字段;当前页面没有文章详情跳转逻辑,因此关闭了标题点击。

启动调用显式选择第一篇,随后打开系统朗读面板,再查询当前文章状态。文章列表和起播位置在同一个调用中传入,能够避免页面显示第一篇、控件却从另一篇开始的错位。应用保存正文的方式也很直接:当前两篇内容都在启动前读入,不依赖网络请求,因此没有内容尚未加载、控件却已经开始播放的异步缺口。

文章内容、朗读控件与面板的调用关系

内容列表交给朗读服务后,面板显示与播放状态仍需分别处理。隐藏面板只改变界面呈现,不能据此判断文章是否暂停。页面因此分别提供显示面板和暂停朗读入口,用户关闭面板后也可以通过播放状态确认朗读是否继续。

三、从面板操作观察文章与播放状态

进入长文本朗读页面,先核对输入区域显示两篇中文内容,以及打开面板、暂停、继续、下一篇、显示面板和停止入口。这个页面是应用提供的控制区域,系统朗读面板还没有打开。操作入口齐全以后,再点击打开朗读面板,等待控件完成初始化并显示界面。

长文本朗读的内容说明与操作入口

面板出现以后,核对文章标题和播放控制。当前第一篇先返回等待状态,随后变为正在播放,进度开始按周期增加;应用也收到了面板显示事件。标题显示当前文章,状态回调显示播放或暂停状态。

点击暂停朗读以后,第一篇返回暂停状态;再次点击继续朗读,状态恢复为播放,进度继续变化。当前返回的状态值与控件枚举一致,播放为1,暂停为2。应用可以使用枚举含义更新按钮和提示,不能把这些值与自己随意定义的页面阶段混用,否则同一个数字在两个对象里可能代表不同意思。

继续播放以后,再点击下一篇内容,返回的文章标识从第一篇变为第二篇。第二篇进入播放状态,并带有最后一篇标记,进度也从这篇内容重新开始变化。

系统朗读面板与实际播放控制

截图展示系统面板中的文章标题和播放控制,切换到第二篇的标识与状态可以结合运行记录核对。配套短视频展示了真实手机画面中的面板和按钮变化,但没有声音轨道;朗读语气和扬声器效果仍需要实际收听。

进度回调中包含文章标识、当前位置和上报类型。当前周期设置为一秒,适合观察状态推进,应用没有把它转换成精确到毫秒的播放时间轴。用户暂停或者停止时,上报类型也会变化,处理时应该先确定事件属于哪篇文章,再读取相应字段。当前停止记录中还出现空文章标识和空当前位置,因此不能假定每次回调都具有完整的活动文章信息。

当前页面把原始状态保留为文字,方便查看控件返回的状态和文章标识。

四、停止与释放需要等前面的操作结束

朗读控件的使用时间可能比页面初始化更长,所以关闭页面时需要处理仍在进行的操作。当前服务把启动与释放排进同一条异步序列,等待前面的操作结束后再处理下一项。用户如果在初始化尚未完成时立即返回,释放操作就不会抢先执行,随后又留下一个迟到的初始化对象。这是当前应用减少生命周期交错的实现选择。

暂停和停止也需要分别处理。暂停用于稍后继续当前位置,停止会结束朗读、关闭面板并重置相关状态。当前服务在用户离开功能时调用停止,再注销自己注册的三类监听,随后释放控件。这个顺序让页面停止接收无关状态,下一次进入时重新初始化,控件就有了清楚的使用起点。

  private async releaseInternal(): Promise<void> {
    if (!this.initialized) { return; }
    await TextReader.stop();
    TextReader.off('stateChange', this.state);
    TextReader.off('showPanel', this.panel);
    TextReader.off('readProgress', this.progress);
    await TextReader.release();
    this.initialized = false;
  }

注销监听时传入的是当初注册的回调对象。当前服务把三个回调保存为成员,注册与注销使用同一份引用。

停止回调到来时,当前文章可能已经移出活动列表,或者标识变为空。页面需要结束本次显示,避免继续把上一条文章的进度拼接到当前状态里。运行记录保留了第二篇播放到停止的状态变化,可以对照检查这一步。

前一个异步操作失败以后,后续释放仍然需要有机会执行。当前服务先接住上一项的拒绝,再继续安排下一项操作,使一次失败不会让整个序列永久停在拒绝状态。具体错误仍然由发起操作的页面接收。

启动和释放使用同一条异步序列以后,页面在快速退出或重新打开时也能按顺序处理控件。系统面板负责播放交互,应用则根据文章标识和状态回调更新自己的显示。

总结

直接播报适合提交一段文字并等待播放完成;需要文章列表和播放面板时,TextReader 提供了面向阅读场景的控件。应用准备窗口、文章标识、标题和正文,控件负责展示面板并提供暂停、继续与切换操作。

页面通过文章状态和进度回调确定当前朗读内容,面板是否显示则单独处理。离开页面时,服务按顺序停止、注销监听并释放控件;两篇本地内容的切换展示了文章身份如何贯穿列表、面板和状态更新。

核心关键代码

import { TextReader } from '@kit.SpeechKit';
import { common } from '@kit.AbilityKit';
import { SpeechSink } from './SpeechSession';

export class ReadingPanel {
  private pending: Promise<void> = Promise.resolve();
  private initialized: boolean = false;
  private sink: SpeechSink;
  constructor(sink: SpeechSink) { this.sink = sink; }
  private state = (state: TextReader.ReadState): void => { this.sink('readerState', JSON.stringify(state)); };
  private panel = (): void => { this.sink('readerPanel', '朗读面板已显示'); };
  private progress = (progress: TextReader.ReadProgress): void => { this.sink('readerProgress', JSON.stringify(progress)); };
  // 将启动与释放排进同一队列,避免初始化和退出操作交错。
  start(context: common.UIAbilityContext, first: string, second: string): Promise<void> {
    this.pending = this.pending.catch(() => {}).then(() => this.startInternal(context, first, second));
    return this.pending;
  }
  private async startInternal(context: common.UIAbilityContext, first: string, second: string): Promise<void> {
    // 首次使用时初始化控件,并注册状态、面板和进度回调。
    if (!this.initialized) {
      await TextReader.init(context, { isVoiceBrandVisible: false, online: 1,
        keepBackgroundRunning: false, isMinibarNeeded: true, reportProgressPeriod: 1000 });
      this.initialized = true;
      TextReader.on('stateChange', this.state);
      TextReader.on('showPanel', this.panel);
      TextReader.on('readProgress', this.progress);
    }
    // 两篇正文使用不同 ID,回调可以据此区分当前文章。
    const list: TextReader.ReadInfo[] = [
      { id: 'reading1', title: { text: '城市公园的游览路线', isClickable: false }, bodyInfo: first },
      { id: 'reading2', title: { text: '社区图书室的整理活动', isClickable: false }, bodyInfo: second }
    ];
    // 从第一篇开始朗读,再显示面板并查询当前状态。
    await TextReader.start(list, 'reading1');
    TextReader.showPanel();
    this.sink('readerQuery', JSON.stringify(TextReader.queryReadState()));
  }
  pause(): void { TextReader.pause(); }
  resume(): void { TextReader.resume(); }
  show(): void { TextReader.showPanel(); }
  next(): void { TextReader.playNext(); }
  // 无论前一项是否成功,都将释放操作接在队列末尾。
  release(): Promise<void> {
    this.pending = this.pending.catch(() => {}).then(() => this.releaseInternal());
    return this.pending;
  }
  private async releaseInternal(): Promise<void> {
    if (!this.initialized) { return; }
    await TextReader.stop();
    // 注销时使用注册时保存的回调,再释放控件资源。
    TextReader.off('stateChange', this.state);
    TextReader.off('showPanel', this.panel);
    TextReader.off('readProgress', this.progress);
    await TextReader.release();
    this.initialized = false;
  }
}
Logo

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

更多推荐