OpenHarmony 英语学习 App 实战:基于艾宾浩斯曲线的单词复习系统设计

摘要

背单词最难的不是“第一次记住”,而是“过几天还记得”。所以英语学习 App 不能只做词汇列表,还需要一套复习调度系统。本文以「英语视界 YingYu」项目为例,分享如何在 OpenHarmony/HarmonyOS 应用中实现基于 SM-2 思路的间隔重复复习系统。🧠📚

项目相关文件包括:

entry/src/main/ets/utils/SpacedRepetition.ts
entry/src/main/ets/pages/ReviewCenter.ets
entry/src/main/ets/utils/StorageService.ts
entry/src/main/ets/model/DataModels.ts

本文会从数据模型、算法计算、复习记录、待复习查询、翻转卡片交互几个角度展开。

一、为什么要做间隔重复?

如果用户每天学习 10 个新词,30 天就是 300 个。没有复习机制的话,用户很快会发现:学过的单词不断遗忘,学习成就感下降。

间隔重复的核心思想是:

  • 刚学会的单词很快复习;
  • 熟悉的单词延长复习间隔;
  • 忘记的单词重新拉回短间隔;
  • 每次复习结果都会影响下一次复习时间。

这比固定“每天复习全部单词”更高效。

二、复习记录数据模型

项目中使用 ReviewRecord 描述一个单词的复习状态:

export interface ReviewRecord {
  wordId: string
  wordType: 'vocabulary' | 'custom' | 'funEnglish' | 'grammar'
  learningDate: string
  reviewDates: string[]
  nextReviewDate: string
  easeFactor: number
  interval: number
  repetitions: number
}

字段说明:

  • wordId:单词 ID;
  • wordType:单词来源;
  • learningDate:首次学习日期;
  • reviewDates:历史复习日期;
  • nextReviewDate:下次复习日期;
  • easeFactor:难度因子;
  • interval:当前间隔天数;
  • repetitions:连续记住次数。

这几个字段足够支撑一个轻量级复习系统。

三、SM-2 参数设计

SpacedRepetition.ts 中定义了两个关键参数:

const MIN_EASE_FACTOR = 1.3
const INITIAL_EASE_FACTOR = 2.5

easeFactor 可以理解为“这个词对用户来说有多容易”。越容易,下一次复习间隔增长越快;越难,间隔增长越慢。

项目还定义了 0-5 的质量等级:

// 0: 完全忘记
// 1: 错误,但看到答案后想起
// 2: 错误,但感觉快想起来了
// 3: 正确,但需要一些努力
// 4: 正确,很快想起
// 5: 正确,非常轻松

虽然当前 UI 中使用“认识/不认识”的简化交互,但算法层保留质量等级,有利于后续扩展更精细的复习反馈。

四、计算下一次复习时间

核心函数是 calculateNextReview()

function calculateNextReview(
  record: ReviewRecord,
  quality: number
): { nextDate: string, interval: number, easeFactor: number, repetitions: number } {
  let { easeFactor, interval, repetitions } = record

  easeFactor = easeFactor + (0.1 - (5 - quality) * (0.08 + (5 - quality) * 0.02))
  if (easeFactor < MIN_EASE_FACTOR) {
    easeFactor = MIN_EASE_FACTOR
  }

  if (quality < 3) {
    repetitions = 0
    interval = 1
  } else {
    if (repetitions === 0) {
      interval = 1
    } else if (repetitions === 1) {
      interval = 6
    } else {
      interval = Math.round(interval * easeFactor)
    }
    repetitions++
  }

  interval = Math.min(interval, 30)

  const nextDate = new Date()
  nextDate.setDate(nextDate.getDate() + interval)

  return {
    nextDate: nextDate.toISOString().split('T')[0],
    interval,
    easeFactor,
    repetitions
  }
}

这段逻辑有几个关键点:

  • 回答错误:重置连续次数,间隔回到 1 天;
  • 第一次记住:1 天后复习;
  • 第二次记住:6 天后复习;
  • 后续记住:根据 interval * easeFactor 拉长间隔;
  • 最大间隔限制为 30 天,避免复习间隔过长。

对学生学习 App 来说,限制最大间隔很实用,因为中小学生词汇量和课程节奏通常需要更频繁的巩固。

五、记录一次复习结果

当用户完成一次复习时,调用 recordReview()

export function recordReview(
  wordId: string,
  wordType: ReviewRecord['wordType'],
  quality: number
): ReviewResult {
  let record = getReviewRecord(wordId)

  if (!record) {
    record = createReviewRecord(wordId, wordType)
  }

  const result = calculateNextReview(record, quality)
  const today = new Date().toISOString().split('T')[0]
  const reviewDates = [...record.reviewDates, today]

  const updatedRecord: ReviewRecord = {
    ...record,
    reviewDates,
    nextReviewDate: result.nextDate,
    interval: result.interval,
    easeFactor: result.easeFactor,
    repetitions: result.repetitions
  }

  updateReviewRecord(wordId, updatedRecord)

  return {
    wordId,
    quality,
    newRecord: updatedRecord
  }
}

这里遵循了清晰的数据流:

  1. 获取已有复习记录;
  2. 没有记录则创建;
  3. 根据质量等级计算下一次复习;
  4. 写入今天的复习日期;
  5. 更新持久化记录;
  6. 返回更新后的结果。

六、创建复习记录

复习记录由 StorageService.ts 创建:

export function createReviewRecord(wordId: string, wordType: ReviewRecord['wordType']): ReviewRecord {
  const records = getReviewRecords()
  const today = new Date().toISOString().split('T')[0]
  const nextDate = new Date()
  nextDate.setDate(nextDate.getDate() + 1)

  const newRecord: ReviewRecord = {
    wordId,
    wordType,
    learningDate: today,
    reviewDates: [],
    nextReviewDate: nextDate.toISOString().split('T')[0],
    easeFactor: 2.5,
    interval: 1,
    repetitions: 0
  }

  records.push(newRecord)
  saveReviewRecords(records)
  return newRecord
}

首次学习后,默认安排第二天复习,这符合记忆曲线的基本规律。

七、查询今日待复习单词

待复习查询逻辑非常直接:

export function getDueReviewWords(): ReviewRecord[] {
  const records = getReviewRecords()
  const today = new Date().toISOString().split('T')[0]
  return records.filter(r => r.nextReviewDate <= today && !r.wordId.startsWith('_mastered_'))
}

只要 nextReviewDate <= today,就说明该单词已经到复习时间。

这里还过滤了 _mastered_ 开头的记录,便于后续处理“已掌握”或特殊状态。

八、今日复习统计

首页或复习中心可以通过 getTodayReviewStats() 展示复习进度:

export function getTodayReviewStats(): {
  total: number,
  due: number,
  reviewed: number,
  remaining: number
} {
  const records = getReviewRecords()
  const today = new Date().toISOString().split('T')[0]

  const due = records.filter(r => r.nextReviewDate <= today && !r.wordId.startsWith('_mastered_')).length
  const reviewed = records.filter(r => r.reviewDates.includes(today)).length
  const total = records.length
  const remaining = Math.max(0, due - reviewed)

  return { total, due, reviewed, remaining }
}

再进一步生成提醒文案:

export function getReviewReminderText(): string {
  const stats = getTodayReviewStats()
  if (stats.due === 0) {
    return '今日复习已完成'
  }
  if (stats.remaining === 0) {
    return `今日复习已完成 ${stats.reviewed} 个`
  }
  return `还有 ${stats.remaining} 个单词等待复习`
}

这种文案可以放在首页、通知、实况窗或每日任务卡片中。

九、复习中心:翻转卡片交互

ReviewCenter.ets 中实现了复习页面。页面先加载到期复习单词:

loadReviewWords() {
  const dueRecords = getDueReviewWords()
  const allWords = getCustomWords()
  const wordsMap = new Map<string, CustomWord>()

  for (let i = 0; i < allWords.length; i++) {
    const w = allWords[i]
    wordsMap.set(w.id, w)
  }

  const items: ReviewItem[] = []
  for (let i = 0; i < dueRecords.length; i++) {
    const record = dueRecords[i]
    const word = wordsMap.get(record.wordId)
    if (word) {
      items.push({ word: word, record: record })
    }
  }

  this.reviewItems = items
  this.totalCount = this.reviewItems.length
}

这里把复习记录和单词详情合并成 ReviewItem,方便 UI 展示。

十、卡片翻转状态

复习中心使用 isFlipped 控制正反面:

flipCard() {
  animateTo({
    duration: 300,
    curve: Curve.EaseOut
  }, () => {
    this.isFlipped = !this.isFlipped
  })
}

正面展示英文和音标:

Text(item.word.word)
  .fontSize(40)
  .fontWeight(FontWeight.Bold)

if (item.word.phonetic) {
  Text(item.word.phonetic)
    .fontSize(20)
    .margin({ top: 8 })
}

反面展示释义和例句:

Text(item.word.translation)
  .fontSize(28)
  .fontWeight(FontWeight.Bold)

if (item.word.example) {
  Text(item.word.example)
    .fontSize(16)
    .textAlign(TextAlign.Center)
    .margin({ top: 20 })
}

翻转卡片非常适合背单词:先回忆,再查看答案,最后判断是否认识。

十一、认识/不认识:简化用户反馈

当前 UI 中通过两个按钮让用户反馈:

Button('不认识')
  .onClick(() => this.handleReview(false))

Button('认识')
  .onClick(() => this.handleReview(true))

handleReview() 根据结果更新间隔:

if (remembered) {
  newRepetitions++
  newInterval = Math.ceil(newInterval * newEaseFactor)
  newEaseFactor = Math.max(1.3, newEaseFactor + 0.1)
} else {
  newRepetitions = 0
  newInterval = 1
  newEaseFactor = Math.max(1.3, newEaseFactor - 0.2)
}

这是一种更适合低龄用户的交互:不要求用户判断 0-5 分,只要选择“认识/不认识”。

十二、小结

本文结合「英语视界 YingYu」项目,拆解了一个 OpenHarmony 英语学习 App 的复习系统:

  • 使用 ReviewRecord 保存复习状态;
  • 通过 easeFactorintervalrepetitions 控制复习节奏;
  • 使用 SM-2 思路计算下一次复习日期;
  • 通过 getDueReviewWords() 查询今日待复习单词;
  • 使用翻转卡片完成“回忆 - 查看 - 判断”流程;
  • 通过“认识/不认识”降低操作成本。

背单词功能不是词库越大越好,真正关键的是让用户在正确的时间复习正确的单词。间隔重复系统,就是英语学习 App 的核心发动机之一。🚀

img

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐