鸿蒙学习实战之路-Reader Kit阅读进度通知最佳实践

朋友们,是不是经常遇到这种情况:用户看了半天书,突然接个电话或者应用被杀,下次打开还得从头开始翻?别慌,Reader Kit提供了阅读进度通知功能,可以实时保存阅读进度,让用户下次打开自动跳转到上次阅读的位置 o(╯□╰)o

今天这篇,我就手把手带你实现阅读进度保存和恢复功能,教你怎么用pageShow回调自动保存进度,全程不超过5分钟(不含数据库配置时间)~


一、为什么需要阅读进度保存?

想象一下场景:

  • 用户看了50页书,突然接电话退出应用
  • 应用被系统后台清理
  • 用户主动退出但没记住看到哪一页

没有进度保存的话,用户下次打开就得从头翻,体验超级差 o(╥﹏╥)o

Reader Kit的阅读进度通知功能,会在每页渲染完成时自动回调,告诉你当前页的精确位置,你只需要把这些信息存到数据库就行了~

二、阅读进度通知的业务流程

整个流程就像这样:

排版引擎渲染完一页
    ↓
触发pageShow回调
    ↓
返回页面渲染信息(domPos、resourceIndex等)
    ↓
将进度信息保存到数据库
    ↓
用户下次打开时从数据库读取
    ↓
调用startPlay跳转到上次位置

简单来说,就是"渲染完成 → 自动回调 → 保存进度 → 下次恢复",是不是超简单? (┓( ´∀` )┏

三、需要用到的接口

阅读进度通知主要涉及2个接口:

接口名 描述
on(‘pageShow’) 注册页面展示回调,渲染完成时触发
off(‘pageShow’) 注销页面展示回调,页面销毁时调用

核心是PageDataInfo对象,包含了当前页的所有信息:

  • resourceIndex - 当前章节索引
  • startDomPos - 当前页起始位置
  • totalPageCount - 总页数
  • 其他元数据…

就像一个精确的书签,告诉用户上次看到哪里了 _

四、开发准备

在开始之前,你需要先按照之前的教程构建一个基本的阅读器,就像要有书才能记录进度一样~

如果没有构建阅读器,可以先去看看"构建阅读器"那篇文章。

另外,你需要准备一个数据库来存储阅读进度,可以选择:

  • 用户首选项(Preferences) - 适合简单的键值存储
  • 关系型数据库(RelationalStore) - 适合复杂的进度管理
  • 键值型数据库(KV-Store) - 适合高性能读写

这里我以用户首选项为例,因为它最简单好用~

五、具体实现步骤

1. 注册pageShow监听器

在阅读页的aboutToAppear生命周期中注册监听器:

import { readerCore } from '@kit.ReaderKit';
import { preferences } from '@kit.ArkData';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { common } from '@kit.AbilityKit';

@Entry
@Component
struct Reader {
  private readerComponentController: readerCore.ReaderComponentController = new readerCore.ReaderComponentController();
  private pref: preferences.Preferences | null = null;

  aboutToAppear(): void {
    this.setOnPageShowListener();
    this.initPreferences();
  }

  /**
   * 初始化用户首选项
   */
  private async initPreferences() {
    let context = this.getUIContext().getHostContext() as common.UIAbilityContext;
    try {
      this.pref = await preferences.getPreferences(context, 'reader_progress');
      hilog.info(0x0000, 'testTag', 'initPreferences succeeded');
    } catch (err) {
      hilog.error(0x0000, 'testTag', `initPreferences failed, Code is ${err.code}, message is ${err.message}`);
    }
  }

  /**
   * 注册页面展示监听器
   */
  private async setOnPageShowListener() {
    try {
      this.readerComponentController.on('pageShow', (data: readerCore.PageDataInfo): void => {
        hilog.info(0x0000, 'testTag', 'pageshow: data is: ' + JSON.stringify(data));
        // 在这里保存阅读进度
        this.saveReadingProgress(data);
      });
    } catch (err) {
      hilog.error(0x0000, 'testTag', `failed to init, Code is ${err.code}, message is ${err.message}`);
    }
  }
}

🥦 西兰花小贴士:
pageShow回调会在每页渲染完成时触发,翻页、跳转都会触发,所以可以实时保存进度,不怕用户意外退出~

2. 保存阅读进度到数据库

pageShow回调中保存进度信息:

/**
 * 保存阅读进度
 */
private async saveReadingProgress(data: readerCore.PageDataInfo) {
  if (!this.pref) {
    return;
  }

  try {
    // 构造进度数据
    let progressData = {
      resourceIndex: data.resourceIndex,
      startDomPos: data.startDomPos,
      totalPageCount: data.totalPageCount,
      currentPageIndex: data.currentPageIndex,
      updateTime: new Date().getTime()
    };

    // 保存到用户首选项
    await this.pref.put('reading_progress', JSON.stringify(progressData));
    await this.pref.flush();

    hilog.info(0x0000, 'testTag', 'saveReadingProgress succeeded');
  } catch (err) {
    hilog.error(0x0000, 'testTag', `saveReadingProgress failed, Code is ${err.code}, message is ${err.message}`);
  }
}

🥦 西兰花警告:
记得在保存完成后调用flush()方法,否则数据可能不会立即写入存储,用户退出后可能会丢失进度 o(╯□╰)o

3. 从数据库读取进度并恢复

在进入阅读页时,读取上次保存的进度并跳转:

/**
 * 读取并恢复阅读进度
 */
private async loadAndRestoreProgress(filePath: string) {
  if (!this.pref) {
    // 没有初始化首选项,从头开始
    this.startPlay(filePath, 0, '');
    return;
  }

  try {
    // 读取保存的进度
    let progressStr = await this.pref.get('reading_progress', '');
    if (!progressStr) {
      // 没有保存过进度,从头开始
      this.startPlay(filePath, 0, '');
      return;
    }

    let progressData = JSON.parse(progressStr as string);
    hilog.info(0x0000, 'testTag', 'loadAndRestoreProgress: ' + JSON.stringify(progressData));

    // 恢复阅读进度
    this.startPlay(
      filePath,
      progressData.resourceIndex,
      progressData.startDomPos
    );
  } catch (err) {
    hilog.error(0x0000, 'testTag', `loadAndRestoreProgress failed, Code is ${err.code}, message is ${err.message}`);
    // 读取失败,从头开始
    this.startPlay(filePath, 0, '');
  }
}

/**
 * 打开书籍
 */
private async startPlay(filePath: string, spineIndex: number, domPos: string) {
  try {
    let context = this.getUIContext().getHostContext() as common.UIAbilityContext;
    let initPromise = this.readerComponentController.init(context);
    let bookParserHandler = bookParser.getDefaultHandler(filePath);
    let result: [bookParser.BookParserHandler, void] = await Promise.all([bookParserHandler, initPromise]);
    this.bookParserHandler = result[0];

    this.readerComponentController.setPageConfig(this.readerSetting);
    this.readerComponentController.registerBookParser(this.bookParserHandler);
    this.readerComponentController.startPlay(spineIndex || 0, domPos);
  } catch (err) {
    hilog.error(0x0000, 'testTag', 'startPlay: err: ' + JSON.stringify(err));
  }
}

4. 页面销毁时注销监听器

aboutToDisappear生命周期中注销监听器:

aboutToDisappear(): void {
  this.readerComponentController.off('pageShow');
  this.readerComponentController.releaseBook();
}

🥦 西兰花小贴士:
注销监听器是个好习惯,可以避免内存泄漏,虽然鸿蒙系统会在页面销毁时自动清理,但手动注销更保险~

5. 完整示例代码

下面是一个完整的示例,展示如何实现阅读进度保存和恢复:

import { common } from '@kit.AbilityKit';
import { bookParser, ReadPageComponent, readerCore } from '@kit.ReaderKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { display } from '@kit.ArkUI';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { preferences } from '@kit.ArkData';

@Entry
@Component
struct Reader {
  private readerComponentController: readerCore.ReaderComponentController = new readerCore.ReaderComponentController();
  private bookParserHandler: bookParser.BookParserHandler | null = null;
  private pref: preferences.Preferences | null = null;

  private readerSetting: readerCore.ReaderSetting = {
    fontName: '系统字体',
    fontPath: '',
    fontSize: 18,
    fontColor: '#000000',
    fontWeight: 400,
    lineHeight: 1.9,
    nightMode: false,
    themeColor: 'rgba(248, 249, 250, 1)',
    themeBgImg: '',
    flipMode: '0',
    scaledDensity: display.getDefaultDisplaySync().scaledDensity > 0 ? display.getDefaultDisplaySync().scaledDensity : 1,
    viewPortWidth: 1260,
    viewPortHeight: 2720,
  };

  @State isLoading: boolean = true;

  aboutToAppear(): void {
    this.initPreferences().then(() => {
      this.setOnPageShowListener();
      this.registerPageShowListener();
      this.loadAndRestoreProgress();
    });
  }

  /**
   * 初始化用户首选项
   */
  private async initPreferences() {
    let context = this.getUIContext().getHostContext() as common.UIAbilityContext;
    try {
      this.pref = await preferences.getPreferences(context, 'reader_progress');
      hilog.info(0x0000, 'testTag', 'initPreferences succeeded');
    } catch (err) {
      hilog.error(0x0000, 'testTag', `initPreferences failed, Code is ${err.code}, message is ${err.message}`);
    }
  }

  /**
   * 注册页面展示监听器
   */
  private async setOnPageShowListener() {
    try {
      this.readerComponentController.on('pageShow', (data: readerCore.PageDataInfo): void => {
        hilog.info(0x0000, 'testTag', 'pageshow: data is: ' + JSON.stringify(data));
        this.saveReadingProgress(data);
      });
    } catch (err) {
      hilog.error(0x0000, 'testTag', `failed to init, Code is ${err.code}, message is ${err.message}`);
    }
  }

  /**
   * 注册页面展示监听器(用于控制加载状态)
   */
  private registerPageShowListener(): void {
    this.readerComponentController.on('pageShow', (data: readerCore.PageDataInfo): void => {
      hilog.info(0x0000, 'testTag', 'pageshow: data is: ' + JSON.stringify(data));
      if (data.state === readerCore.PageState.PAGE_ON_SHOW) {
        this.isLoading = false;
      }
    });
  }

  /**
   * 保存阅读进度
   */
  private async saveReadingProgress(data: readerCore.PageDataInfo) {
    if (!this.pref) {
      return;
    }

    try {
      let progressData = {
        resourceIndex: data.resourceIndex,
        startDomPos: data.startDomPos,
        totalPageCount: data.totalPageCount,
        currentPageIndex: data.currentPageIndex,
        updateTime: new Date().getTime()
      };

      await this.pref.put('reading_progress', JSON.stringify(progressData));
      await this.pref.flush();

      hilog.info(0x0000, 'testTag', 'saveReadingProgress succeeded');
    } catch (err) {
      hilog.error(0x0000, 'testTag', `saveReadingProgress failed, Code is ${err.code}, message is ${err.message}`);
    }
  }

  /**
   * 读取并恢复阅读进度
   */
  private async loadAndRestoreProgress() {
    if (!this.pref) {
      this.startPlayDefault();
      return;
    }

    try {
      let progressStr = await this.pref.get('reading_progress', '');
      if (!progressStr) {
        this.startPlayDefault();
        return;
      }

      let progressData = JSON.parse(progressStr as string);
      hilog.info(0x0000, 'testTag', 'loadAndRestoreProgress: ' + JSON.stringify(progressData));

      this.startPlayWithProgress(progressData.resourceIndex, progressData.startDomPos);
    } catch (err) {
      hilog.error(0x0000, 'testTag', `loadAndRestoreProgress failed, Code is ${err.code}, message is ${err.message}`);
      this.startPlayDefault();
    }
  }

  /**
   * 从头开始阅读
   */
  private async startPlayDefault() {
    let context = this.getUIContext().getHostContext() as common.UIAbilityContext;
    let filePath: string = `${context.filesDir}/abc.epub`;
    this.startPlayWithProgress(0, '');
  }

  /**
   * 从指定进度开始阅读
   */
  private async startPlayWithProgress(spineIndex: number, domPos: string) {
    let context = this.getUIContext().getHostContext() as common.UIAbilityContext;
    let filePath: string = `${context.filesDir}/abc.epub`;

    try {
      let initPromise = this.readerComponentController.init(context);
      let bookParserHandler = bookParser.getDefaultHandler(filePath);
      let result: [bookParser.BookParserHandler, void] = await Promise.all([bookParserHandler, initPromise]);
      this.bookParserHandler = result[0];

      this.readerComponentController.setPageConfig(this.readerSetting);
      this.readerComponentController.registerBookParser(this.bookParserHandler);
      this.readerComponentController.startPlay(spineIndex || 0, domPos);
    } catch (err) {
      hilog.error(0x0000, 'testTag', 'startPlay: err: ' + JSON.stringify(err));
    }
  }

  aboutToDisappear(): void {
    this.readerComponentController.off('pageShow');
    this.readerComponentController.releaseBook();
  }

  build() {
    Stack() {
      ReadPageComponent({
        controller: this.readerComponentController,
        readerCallback: (err: BusinessError, data: readerCore.ReaderComponentController) => {
          this.readerComponentController = data;
        }
      })
      Row() {
        Text('加载中...')
      }
      .width('100%')
      .height('100%')
      .justifyContent(FlexAlign.Center)
      .backgroundColor(Color.White)
      .visibility(this.isLoading ? Visibility.Visible : Visibility.None)
    }.width('100%').height('100%')
  }
}

六、进阶优化

1. 按书籍ID保存进度

如果你有多个书籍,可以按书籍ID保存不同的进度:

/**
 * 保存阅读进度(按书籍ID)
 */
private async saveReadingProgress(bookId: string, data: readerCore.PageDataInfo) {
  if (!this.pref) {
    return;
  }

  try {
    let progressData = {
      resourceIndex: data.resourceIndex,
      startDomPos: data.startDomPos,
      totalPageCount: data.totalPageCount,
      currentPageIndex: data.currentPageIndex,
      updateTime: new Date().getTime()
    };

    let key = `reading_progress_${bookId}`;
    await this.pref.put(key, JSON.stringify(progressData));
    await this.pref.flush();

    hilog.info(0x0000, 'testTag', 'saveReadingProgress succeeded, key: ' + key);
  } catch (err) {
    hilog.error(0x0000, 'testTag', `saveReadingProgress failed, Code is ${err.code}, message is ${err.message}`);
  }
}

2. 添加阅读进度百分比显示

可以计算阅读进度百分比,显示给用户:

@State readingProgress: number = 0;

private async saveReadingProgress(data: readerCore.PageDataInfo) {
  // ... 保存逻辑

  // 计算阅读进度百分比
  if (data.totalPageCount > 0) {
    this.readingProgress = Math.round((data.currentPageIndex / data.totalPageCount) * 100);
  }
}

build() {
  Stack() {
    ReadPageComponent({
      controller: this.readerComponentController,
      readerCallback: (err: BusinessError, data: readerCore.ReaderComponentController) => {
        this.readerComponentController = data;
      }
    })

    // 显示阅读进度
    Text(`${this.readingProgress}%`)
      .position({ x: '50%', y: '95%'' })
      .translate({ x: '-50%', y: '-50%'' })
      .fontSize(12)
      .fontColor('#999999')
  }
}

3. 添加阅读时间统计

记录用户的阅读时间,提供更丰富的数据:

/**
 * 保存阅读进度(包含时间统计)
 */
private async saveReadingProgress(bookId: string, data: readerCore.PageDataInfo) {
  if (!this.pref) {
    return;
  }

  try {
    // 获取之前的进度数据
    let progressStr = await this.pref.get(`reading_progress_${bookId}`, '');
    let totalTime = 0;

    if (progressStr) {
      let oldData = JSON.parse(progressStr as string);
      totalTime = oldData.totalTime || 0;
    }

    let progressData = {
      resourceIndex: data.resourceIndex,
      startDomPos: data.startDomPos,
      totalPageCount: data.totalPageCount,
      currentPageIndex: data.currentPageIndex,
      updateTime: new Date().getTime(),
      totalTime: totalTime + 30 // 假设每页阅读30秒
    };

    await this.pref.put(`reading_progress_${bookId}`, JSON.stringify(progressData));
    await this.pref.flush();
  } catch (err) {
    hilog.error(0x0000, 'testTag', `saveReadingProgress failed, Code is ${err.code}, message is ${err.message}`);
  }
}

七、注意事项

🥦 西兰花警告:

  1. 记得调用flush(): 保存进度后记得调用flush(),否则数据可能不会立即写入存储

  2. 处理异常情况: 如果读取进度失败,要给用户一个默认的起点,比如从头开始

  3. 避免频繁写入: 虽然每页都会触发pageShow,但实际保存频率可以控制,比如每5页保存一次,避免频繁IO操作

  4. 多本书籍管理: 如果用户有多本书,记得按书籍ID区分不同的进度

  5. 进度数据验证: 读取进度时要验证数据的有效性,避免旧数据导致崩溃


八、文档资源

官方文档链接:


九、总结

Reader Kit的阅读进度通知功能非常实用,只需要监听pageShow回调,就能实时保存阅读进度,让用户下次打开自动跳转到上次的位置。

核心要点:

  1. pageShow回调会在每页渲染完成时触发
  2. 通过PageDataInfo获取当前页的精确位置
  3. 保存resourceIndexstartDomPos用于恢复进度
  4. 下次打开时读取进度并调用startPlay跳转
  5. 记得调用flush()确保数据写入存储
  6. 可以按书籍ID保存不同的进度

有了这个功能,用户再也不用担心阅读进度丢失了,体验提升不止一个档次! _


下一步行动

建议你:

  1. 先实现基本的进度保存和恢复功能
  2. 添加进度百分比显示,让用户知道自己读到哪了
  3. 支持多本书籍的进度管理
  4. 添加阅读时间统计,提供更丰富的数据

记住,不教理论,只给你能跑的代码和避坑指南! _


我是盐焗西兰花,
不教理论,只给你能跑的代码和避坑指南。
下期见!🥦

Logo

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

更多推荐