鸿蒙开发-疾阅App阅读训练功能技术解析

15 阅读5分钟

疾阅App阅读训练功能技术解析

继续聊聊疾阅App,这次是阅读训练功能。想体验完整功能,可以去鸿蒙应用市场搜索"疾阅"下载。

写在前面

上一篇我们聊了疾阅App的文字动画功能,今天聊聊阅读训练——怎么设计训练计划、记录训练数据、评估训练效果。

阅读训练是个长期过程,需要持续的数据记录和分析。Web端用localStorage或IndexedDB存储训练数据,鸿蒙端用RDB更合适,特别是需要复杂查询和统计的时候。

今天这篇,我会从训练计划、数据记录、效果评估这几个方面,聊聊疾阅App的阅读训练功能。


1. 训练计划:课程设计

疾阅App提供多种训练课程。

课程数据结构:

interface TrainingCourse {
  id: string;
  name: string;
  description: string;
  level: 'beginner' | 'intermediate' | 'advanced';
  lessons: Lesson[];
  totalDuration: number; // 总时长(分钟)
}

interface Lesson {
  id: string;
  title: string;
  type: LessonType;
  content: string;
  targetWPM: number; // 目标阅读速度
  duration: number; // 预计时长(秒)
}

enum LessonType {
  SPEED_READING = 'speed_reading',
  COMPREHENSION = 'comprehension',
  FLASH_READING = 'flash_reading',
  CHUNK_READING = 'chunk_reading'
}

// 预设课程
const trainingCourses: TrainingCourse[] = [
  {
    id: 'basic_speed',
    name: '基础速读',
    description: '适合初学者的速读入门课程',
    level: 'beginner',
    totalDuration: 30,
    lessons: [
      {
        id: 'l1',
        title: '词语闪现',
        type: LessonType.FLASH_READING,
        content: '快速识别闪现的词语,训练视觉反应速度',
        targetWPM: 200,
        duration: 300
      },
      {
        id: 'l2',
        title: '句子速读',
        type: LessonType.SPEED_READING,
        content: '以固定速度阅读句子,逐步提高阅读速度',
        targetWPM: 300,
        duration: 600
      },
      {
        id: 'l3',
        title: '段落理解',
        type: LessonType.COMPREHENSION,
        content: '阅读段落后回答问题,测试理解能力',
        targetWPM: 250,
        duration: 900
      }
    ]
  },
  {
    id: 'advanced_speed',
    name: '高级速读',
    description: '挑战更高阅读速度的进阶课程',
    level: 'advanced',
    totalDuration: 60,
    lessons: [
      {
        id: 'l4',
        title: '块状阅读',
        type: LessonType.CHUNK_READING,
        content: '一次阅读多个词语,扩大视幅',
        targetWPM: 500,
        duration: 600
      },
      {
        id: 'l5',
        title: '快速扫读',
        type: LessonType.SPEED_READING,
        content: '快速扫视文章,提取关键信息',
        targetWPM: 800,
        duration: 900
      }
    ]
  }
];

2. 训练记录:数据存储

ArkTS训练记录管理:

import { relationalStore } from '@kit.ArkData';

interface TrainingRecord {
  id: string;
  courseId: string;
  lessonId: string;
  startTime: number;
  endTime: number;
  wordCount: number;
  wpm: number;
  comprehensionScore?: number;
  completed: boolean;
}

class TrainingDatabase {
  private rdbStore: relationalStore.RdbStore | null = null;

  async init(context: Context) {
    const config: relationalStore.StoreConfig = {
      name: 'reading_training.db',
      securityLevel: relationalStore.SecurityLevel.S1
    };

    this.rdbStore = await relationalStore.getRdbStore(context, config);

    await this.rdbStore.executeSql(`
      CREATE TABLE IF NOT EXISTS training_records (
        id TEXT PRIMARY KEY,
        course_id TEXT NOT NULL,
        lesson_id TEXT NOT NULL,
        start_time INTEGER,
        end_time INTEGER,
        word_count INTEGER,
        wpm REAL,
        comprehension_score INTEGER,
        completed INTEGER DEFAULT 0
      )
    `);
  }

  // 保存训练记录
  async saveRecord(record: TrainingRecord): Promise<void> {
    if (!this.rdbStore) return;

    const bucket: relationalStore.ValuesBucket = {
      id: record.id,
      course_id: record.courseId,
      lesson_id: record.lessonId,
      start_time: record.startTime,
      end_time: record.endTime,
      word_count: record.wordCount,
      wpm: record.wpm,
      comprehension_score: record.comprehensionScore,
      completed: record.completed ? 1 : 0
    };

    await this.rdbStore.insert('training_records', bucket);
  }

  // 获取用户的所有记录
  async getRecords(): Promise<TrainingRecord[]> {
    if (!this.rdbStore) return [];

    const resultSet = await this.rdbStore.querySql(
      'SELECT * FROM training_records ORDER BY start_time DESC'
    );

    const records: TrainingRecord[] = [];
    while (resultSet.goToNextRow()) {
      records.push({
        id: resultSet.getString(resultSet.getColumnIndex('id')),
        courseId: resultSet.getString(resultSet.getColumnIndex('course_id')),
        lessonId: resultSet.getString(resultSet.getColumnIndex('lesson_id')),
        startTime: resultSet.getLong(resultSet.getColumnIndex('start_time')),
        endTime: resultSet.getLong(resultSet.getColumnIndex('end_time')),
        wordCount: resultSet.getLong(resultSet.getColumnIndex('word_count')),
        wpm: resultSet.getDouble(resultSet.getColumnIndex('wpm')),
        comprehensionScore: resultSet.getLong(resultSet.getColumnIndex('comprehension_score')),
        completed: resultSet.getLong(resultSet.getColumnIndex('completed')) === 1
      });
    }
    resultSet.close();

    return records;
  }

  // 获取课程记录
  async getCourseRecords(courseId: string): Promise<TrainingRecord[]> {
    if (!this.rdbStore) return [];

    const resultSet = await this.rdbStore.querySql(
      'SELECT * FROM training_records WHERE course_id = ? ORDER BY start_time DESC',
      [courseId]
    );

    const records: TrainingRecord[] = [];
    while (resultSet.goToNextRow()) {
      records.push({
        id: resultSet.getString(resultSet.getColumnIndex('id')),
        courseId: resultSet.getString(resultSet.getColumnIndex('course_id')),
        lessonId: resultSet.getString(resultSet.getColumnIndex('lesson_id')),
        startTime: resultSet.getLong(resultSet.getColumnIndex('start_time')),
        endTime: resultSet.getLong(resultSet.getColumnIndex('end_time')),
        wordCount: resultSet.getLong(resultSet.getColumnIndex('word_count')),
        wpm: resultSet.getDouble(resultSet.getColumnIndex('wpm')),
        comprehensionScore: resultSet.getLong(resultSet.getColumnIndex('comprehension_score')),
        completed: resultSet.getLong(resultSet.getColumnIndex('completed')) === 1
      });
    }
    resultSet.close();

    return records;
  }

  // 获取统计数据
  async getStatistics(): Promise<TrainingStatistics> {
    const records = await this.getRecords();

    if (records.length === 0) {
      return {
        totalSessions: 0,
        totalWords: 0,
        totalTime: 0,
        averageWPM: 0,
        highestWPM: 0,
        improvement: 0
      };
    }

    const totalWords = records.reduce((sum, r) => sum + r.wordCount, 0);
    const totalTime = records.reduce((sum, r) => sum + (r.endTime - r.startTime), 0);
    const wpmValues = records.map(r => r.wpm);
    const averageWPM = wpmValues.reduce((a, b) => a + b, 0) / wpmValues.length;
    const highestWPM = Math.max(...wpmValues);

    // 计算进步幅度(最近10次 vs 最早10次)
    let improvement = 0;
    if (records.length >= 20) {
      const recent10 = records.slice(0, 10);
      const early10 = records.slice(-10);
      const recentAvg = recent10.reduce((sum, r) => sum + r.wpm, 0) / 10;
      const earlyAvg = early10.reduce((sum, r) => sum + r.wpm, 0) / 10;
      improvement = ((recentAvg - earlyAvg) / earlyAvg) * 100;
    }

    return {
      totalSessions: records.length,
      totalWords,
      totalTime,
      averageWPM: Math.round(averageWPM),
      highestWPM,
      improvement: Math.round(improvement)
    };
  }
}

interface TrainingStatistics {
  totalSessions: number;
  totalWords: number;
  totalTime: number;
  averageWPM: number;
  highestWPM: number;
  improvement: number;
}

3. 训练界面:课程执行

ArkTS训练界面:

@Component
struct TrainingPage {
  @State currentLesson: Lesson | null = null;
  @State isTraining: boolean = false;
  @State elapsedTime: number = 0;
  @State wordsRead: number = 0;
  @State currentWPM: number = 0;

  @Prop courseId: string = '';
  @Prop lessonId: string = '';

  private startTime: number = 0;
  private timer?: number;

  async aboutToAppear() {
    // 加载课程数据
    await this.loadLesson();
  }

  async loadLesson() {
    // 从数据库或本地数据加载
    const course = trainingCourses.find(c => c.id === this.courseId);
    if (course) {
      this.currentLesson = course.lessons.find(l => l.id === this.lessonId) || null;
    }
  }

  build() {
    Column() {
      if (this.currentLesson) {
        // 课程信息
        Text(this.currentLesson.title)
          .fontSize(24)
          .fontWeight(FontWeight.Bold)
          .margin({ top: 20 })

        Text(this.currentLesson.description)
          .fontSize(14)
          .fontColor('#666')
          .margin({ top: 8 })

        // 训练区域
        if (this.isTraining) {
          this.TrainingArea()
        } else {
          this.PreTrainingInfo()
        }
      } else {
        Text('课程加载失败')
          .fontSize(16)
          .fontColor('#F44336')
      }
    }
  }

  @Builder
  PreTrainingInfo() {
    Column() {
      // 目标信息
      Row() {
        this.InfoCard('目标WPM', `${this.currentLesson?.targetWPM}`)
        this.InfoCard('预计时长', `${Math.round((this.currentLesson?.duration || 0) / 60)}分钟`)
      }
      .width('90%')
      .margin({ top: 20 })

      Button('开始训练')
        .width('90%')
        .height(50)
        .margin({ top: 30 })
        .onClick(() => this.startTraining())
    }
  }

  @Builder
  InfoCard(title: string, value: string) {
    Column() {
      Text(value)
        .fontSize(24)
        .fontWeight(FontWeight.Bold)
        .fontColor('#2196F3')

      Text(title)
        .fontSize(14)
        .fontColor('#666')
        .margin({ top: 4 })
    }
    .layoutWeight(1)
    .padding(16)
    .backgroundColor('#fff')
    .borderRadius(12)
    .margin(4)
  }

  @Builder
  TrainingArea() {
    Column() {
      // 训练内容
      if (this.currentLesson?.type === LessonType.SPEED_READING) {
        ScrollingText({
          text: this.currentLesson.content,
          speed: 60000 / this.currentLesson.targetWPM
        })
      } else if (this.currentLesson?.type === LessonType.FLASH_READING) {
        FlashText({ text: '示例词语' })
      }

      // 统计信息
      Row() {
        this.StatDisplay('时间', this.formatTime(this.elapsedTime))
        this.StatDisplay('已读词数', this.wordsRead.toString())
        this.StatDisplay('当前WPM', this.currentWPM.toString())
      }
      .width('90%')
      .margin({ top: 20 })

      // 控制按钮
      Row() {
        Button('暂停')
          .layoutWeight(1)
          .onClick(() => this.pauseTraining())

        Button('结束')
          .layoutWeight(1)
          .margin({ left: 12 })
          .onClick(() => this.endTraining())
      }
      .width('90%')
      .margin({ top: 20 })
    }
  }

  @Builder
  StatDisplay(title: string, value: string) {
    Column() {
      Text(value)
        .fontSize(20)
        .fontWeight(FontWeight.Bold)

      Text(title)
        .fontSize(12)
        .fontColor('#666')
        .margin({ top: 2 })
    }
    .layoutWeight(1)
  }

  startTraining() {
    this.isTraining = true;
    this.startTime = Date.now();
    this.elapsedTime = 0;
    this.wordsRead = 0;

    this.timer = setInterval(() => {
      this.elapsedTime = (Date.now() - this.startTime) / 1000;
      this.calculateWPM();
    }, 100);
  }

  pauseTraining() {
    this.isTraining = false;
    if (this.timer) {
      clearInterval(this.timer);
      this.timer = undefined;
    }
  }

  async endTraining() {
    this.pauseTraining();

    // 保存记录
    const record: TrainingRecord = {
      id: `record_${Date.now()}`,
      courseId: this.courseId,
      lessonId: this.lessonId,
      startTime: this.startTime,
      endTime: Date.now(),
      wordCount: this.wordsRead,
      wpm: this.currentWPM,
      completed: true
    };

    const db = new TrainingDatabase();
    await db.init(getContext());
    await db.saveRecord(record);

    // 显示结果
    this.showResults();
  }

  calculateWPM() {
    if (this.elapsedTime > 0) {
      this.currentWPM = Math.round((this.wordsRead / this.elapsedTime) * 60);
    }
  }

  showResults() {
    // 跳转到结果页面
    router.pushUrl({
      url: 'pages/TrainingResult',
      params: {
        wpm: this.currentWPM,
        wordsRead: this.wordsRead,
        time: this.elapsedTime,
        targetWPM: this.currentLesson?.targetWPM
      }
    });
  }

  formatTime(seconds: number): string {
    const mins = Math.floor(seconds / 60);
    const secs = Math.floor(seconds % 60);
    return `${mins}:${secs.toString().padStart(2, '0')}`;
  }

  aboutToDisappear() {
    if (this.timer) {
      clearInterval(this.timer);
    }
  }
}

4. 进度追踪:数据可视化

ArkTS进度图表:

@Component
struct ProgressChart {
  @State records: TrainingRecord[] = [];
  @State timeRange: 'week' | 'month' | 'year' = 'week';

  async aboutToAppear() {
    await this.loadRecords();
  }

  async loadRecords() {
    const db = new TrainingDatabase();
    await db.init(getContext());
    this.records = await db.getRecords();
  }

  build() {
    Column() {
      // 时间范围选择
      Row() {
        Button('本周')
          .backgroundColor(this.timeRange === 'week' ? '#2196F3' : '#f0f0f0')
          .fontColor(this.timeRange === 'week' ? '#fff' : '#333')
          .onClick(() => this.timeRange = 'week')

        Button('本月')
          .backgroundColor(this.timeRange === 'month' ? '#2196F3' : '#f0f0f0')
          .fontColor(this.timeRange === 'month' ? '#fff' : '#333')
          .margin({ left: 8 })
          .onClick(() => this.timeRange = 'month')

        Button('全年')
          .backgroundColor(this.timeRange === 'year' ? '#2196F3' : '#f0f0f0')
          .fontColor(this.timeRange === 'year' ? '#fff' : '#333')
          .margin({ left: 8 })
          .onClick(() => this.timeRange = 'year')
      }
      .margin({ top: 20 })

      // WPM趋势图
      Canvas(this.drawChart)
        .width('90%')
        .height(250)
        .backgroundColor('#fff')
        .borderRadius(12)
        .margin({ top: 16 })

      // 统计卡片
      Row() {
        this.StatCard('平均WPM', this.getAverageWPM().toString())
        this.StatCard('最高WPM', this.getHighestWPM().toString())
        this.StatCard('训练次数', this.records.length.toString())
      }
      .width('90%')
      .margin({ top: 16 })
    }
  }

  @Builder
  StatCard(title: string, value: string) {
    Column() {
      Text(value)
        .fontSize(20)
        .fontWeight(FontWeight.Bold)
        .fontColor('#2196F3')

      Text(title)
        .fontSize(12)
        .fontColor('#666')
        .margin({ top: 2 })
    }
    .layoutWeight(1)
    .padding(12)
    .backgroundColor('#f5f5f5')
    .borderRadius(8)
    .margin(4)
  }

  drawChart = (ctx: CanvasRenderingContext2D) => {
    const width = 300;
    const height = 250;
    const padding = 40;

    const filteredRecords = this.getFilteredRecords();
    if (filteredRecords.length === 0) return;

    const wpmValues = filteredRecords.map(r => r.wpm);
    const maxWPM = Math.max(...wpmValues);
    const minWPM = Math.min(...wpmValues);

    // 绘制坐标轴
    ctx.beginPath();
    ctx.moveTo(padding, padding);
    ctx.lineTo(padding, height - padding);
    ctx.lineTo(width - padding, height - padding);
    ctx.strokeStyle = '#ddd';
    ctx.stroke();

    // 绘制数据点
    const stepX = (width - 2 * padding) / (filteredRecords.length - 1);

    ctx.beginPath();
    filteredRecords.forEach((record, index) => {
      const x = padding + index * stepX;
      const y = padding + (height - 2 * padding) * (1 - (record.wpm - minWPM) / (maxWPM - minWPM));

      if (index === 0) {
        ctx.moveTo(x, y);
      } else {
        ctx.lineTo(x, y);
      }
    });
    ctx.strokeStyle = '#2196F3';
    ctx.lineWidth = 2;
    ctx.stroke();

    // 绘制目标线
    const targetWPM = 300; // 示例目标
    const targetY = padding + (height - 2 * padding) * (1 - (targetWPM - minWPM) / (maxWPM - minWPM));
    ctx.beginPath();
    ctx.moveTo(padding, targetY);
    ctx.lineTo(width - padding, targetY);
    ctx.strokeStyle = '#F44336';
    ctx.lineWidth = 1;
    ctx.setLineDash([5, 5]);
    ctx.stroke();
  }

  getFilteredRecords(): TrainingRecord[] {
    const now = Date.now();
    let startTime = 0;

    switch (this.timeRange) {
      case 'week':
        startTime = now - 7 * 24 * 60 * 60 * 1000;
        break;
      case 'month':
        startTime = now - 30 * 24 * 60 * 60 * 1000;
        break;
      case 'year':
        startTime = now - 365 * 24 * 60 * 60 * 1000;
        break;
    }

    return this.records.filter(r => r.startTime >= startTime).reverse();
  }

  getAverageWPM(): number {
    const filtered = this.getFilteredRecords();
    if (filtered.length === 0) return 0;
    return Math.round(filtered.reduce((sum, r) => sum + r.wpm, 0) / filtered.length);
  }

  getHighestWPM(): number {
    const filtered = this.getFilteredRecords();
    if (filtered.length === 0) return 0;
    return Math.max(...filtered.map(r => r.wpm));
  }
}

5. 成就系统:激励机制

ArkTS成就系统:

interface Achievement {
  id: string;
  name: string;
  description: string;
  icon: string;
  condition: (stats: TrainingStatistics) => boolean;
  unlocked: boolean;
}

const achievements: Achievement[] = [
  {
    id: 'first_training',
    name: '初次训练',
    description: '完成第一次训练',
    icon: '🎯',
    condition: (stats) => stats.totalSessions >= 1,
    unlocked: false
  },
  {
    id: 'speed_demon',
    name: '速读达人',
    description: '达到500 WPM',
    icon: '⚡',
    condition: (stats) => stats.highestWPM >= 500,
    unlocked: false
  },
  {
    id: 'consistent_reader',
    name: '坚持不懈',
    description: '连续7天训练',
    icon: '🔥',
    condition: (stats) => stats.totalSessions >= 7,
    unlocked: false
  },
  {
    id: 'word_master',
    name: '万词阅读',
    description: '累计阅读10000个词',
    icon: '📚',
    condition: (stats) => stats.totalWords >= 10000,
    unlocked: false
  }
];

@Component
struct AchievementPage {
  @State achievements: Achievement[] = [];
  @State statistics: TrainingStatistics | null = null;

  async aboutToAppear() {
    await this.loadData();
    this.checkAchievements();
  }

  async loadData() {
    const db = new TrainingDatabase();
    await db.init(getContext());
    this.statistics = await db.getStatistics();

    // 加载成就状态
    this.achievements = achievements.map(a => ({
      ...a,
      unlocked: a.condition(this.statistics!)
    }));
  }

  checkAchievements() {
    // 检查是否有新解锁的成就
    this.achievements.forEach(achievement => {
      if (achievement.unlocked) {
        // 检查是否是新解锁
        // ...
      }
    });
  }

  build() {
    Column() {
      Text('成就')
        .fontSize(24)
        .fontWeight(FontWeight.Bold)
        .margin({ top: 20 })

      Text(`已解锁 ${this.achievements.filter(a => a.unlocked).length} / ${this.achievements.length}`)
        .fontSize(14)
        .fontColor('#666')
        .margin({ top: 8 })

      List({ space: 12 }) {
        ForEach(this.achievements, (achievement: Achievement) => {
          ListItem() {
            Row() {
              Text(achievement.icon)
                .fontSize(36)

              Column() {
                Text(achievement.name)
                  .fontSize(16)
                  .fontWeight(FontWeight.Medium)

                Text(achievement.description)
                  .fontSize(14)
                  .fontColor('#666')
                  .margin({ top: 4 })
              }
              .alignItems(HorizontalAlign.Start)
              .margin({ left: 12 })
              .layoutWeight(1)

              if (achievement.unlocked) {
                Text('✓')
                  .fontSize(24)
                  .fontColor('#4CAF50')
              }
            }
            .padding(16)
            .backgroundColor(achievement.unlocked ? '#E8F5E9' : '#f5f5f5')
            .borderRadius(12)
          }
        })
      }
      .margin({ top: 16 })
    }
  }
}

总结

疾阅App的阅读训练功能,从课程设计、数据记录、进度追踪到成就系统,每一部分都有它的技术要点。鸿蒙端的RDB提供了可靠的数据存储,Canvas提供了灵活的图表绘制,让训练数据的管理和展示变得简单高效。

如果你想做学习训练类App,这些功能模块都很有参考价值。课程管理、数据统计、成就激励,这些都能提高用户的参与度和粘性。

疾阅App就聊到这里。下一篇文章,我们换个方向,聊聊演界App的提词器功能。