【句匠|09】HarmonyOS ArkTS 学习统计实战:汇总正确率、连续学习和薄弱类型

学习类应用做到后期,统计页很容易变成"把几个数字摆出来"。这种做法上线后问题很明显:用户看到总答题数,却不知道它来自哪些题库;看到正确率,却不知道空数据时怎么算;看到考试均分,却不知道取的是全部历史还是最近几次;页面在平板或 2in1 宽屏打开时,如果仍然强行单列排列,也会浪费大量空间。

句匠项目里的 LearningStatsPage.ets 没有引入服务端画像,也没有做复杂的数据分析模型。它把真实可用的本地学习数据分成四组:bankProgress、examHistory、favoriteRecords、wrongRecords。页面通过 @StorageLink 读取 AppStorage 中的数组,再交给 libraryb 的 StatService 汇总,最后用概览卡、数据卡和题库进度列表呈现出来。这个链路小,但边界清楚,适合复盘 HarmonyOS 5.0 以上 ArkTS 应用里"本地学习统计页"应该怎样拆分职责。

本文唯一标记:com.jiaweikang.one18。以下内容只基于句匠项目真实源码:entry/src/main/ets/pages/LearningStatsPage.ets、libraryb/src/main/ets/utils/StatService.ets、librarya/src/main/ets/utils/UserDataManager.ets 和题库目录 entry/src/main/ets/mock/MockBanks.ets。需要先说明边界:当前统计页不计算签到连续天数,也不做题型维度的薄弱项排序;它呈现的是答题正确率、考试次数、收藏/错题数量、近 5 次考试均分以及各题库完成度。连续学习的"持续积累"在这个页面里主要体现为题库进度和历史考试数据,真正的签到连续天数属于 DailyCheckInPage.ets 的职责。

一、统计页真正要解决的不是数字展示,而是数据口径

实际项目里,学习统计页最容易出错的地方不是 UI,而是口径不一致。首页显示一个正确率,个人中心显示另一个正确率,题库详情页又用第三套算法,用户一眼就会觉得数据不可信。句匠的做法是把全局统计放到 StatService.summarize,页面只负责把结果渲染出来。

这篇文章会围绕四个问题展开:

问题 源码里的处理方式 结果
总答题数从哪里来 遍历 BankProgress.finished 与练习完成记录一致
正确率如何避免除零 totalAnswered > 0 才计算 空数据时显示 0
考试均分取什么范围 recentAvgScore(examHistory, 5) 默认只看近 5 次
宽屏如何避免单列浪费 currentBp === 'lg' && pageWidth >= 700 宽屏切换三列题库卡片

流程关系可以概括为下面这张图。

这个页面的价值不在于算法复杂,而在于它让页面、统计服务、持久化服务和题库目录各自只做一件事。页面不直接写 Preferences;统计服务不关心 UI;持久化服务不参与页面布局;题库目录只提供静态题库基础信息。

LearningStatsPage 的第一层数据来自 AppStorage。页面通过 @StorageLink 绑定四组学习记录和一个断点状态。

ts 复制代码
@StorageLink('bankProgress') progressList: BankProgress[] = []
@StorageLink('examHistory') examHistory: ExamHistory[] = []
@StorageLink('favoriteRecords') favRecords: FavoriteRecord[] = []
@StorageLink('wrongRecords') wrongRecords: WrongRecord[] = []
@StorageLink('currentBreakpoint') currentBp: string = 'sm'
@State pageWidth: number = 360

这段代码的边界很明确:

字段 作用 页面是否直接修改
progressList 每个题库的已答题数、答对题数和最后章节 否
examHistory 模拟考试历史,包含分数、正确数、用时 否
favRecords 收藏题目记录 否
wrongRecords 错题记录 否
currentBp 应用断点,用于响应式布局 否
pageWidth 当前页面宽度,本页局部状态 是

这里没有在页面里直接调用 Preferences,也没有在 UI 组件内拼接本地存储键名。这一点很重要。统计页只消费已经加载到 AppStorage 的状态,数据初始化由 UserDataManager.init() 完成。

ts 复制代码
static init(context: common.UIAbilityContext | common.Context): void {
  UserDataManager.prefs = preferences.getPreferencesSync(context, { name: UserDataManager.STORE_NAME })
  const progStr = UserDataManager.prefs.getSync(UserDataManager.K_PROGRESS, '[]') as string
  const examStr = UserDataManager.prefs.getSync(UserDataManager.K_EXAM, '[]') as string

  AppStorage.setOrCreate<BankProgress[]>('bankProgress', JSON.parse(progStr) as BankProgress[])
  AppStorage.setOrCreate<ExamHistory[]>('examHistory', JSON.parse(examStr) as ExamHistory[])
}

这段初始化逻辑属于 librarya 的公共能力层。页面只知道 bankProgress 和 examHistory 两个全局状态存在,不需要知道它们被保存在名为 dialect_quiz 的 Preferences 文件里。这样做可以避免一个常见问题:当后续把 Preferences 迁移到 RDB 或增加备份策略时,统计页不需要跟着改存储细节。

三、StatService.summarize 负责统一全局统计口径

统计服务的核心方法是 summarize。它接收四组数组,返回一个 LearningStats 对象。

ts 复制代码
export interface LearningStats {
  totalAnswered: number
  totalCorrect: number
  accuracyPercent: number
  examCount: number
  favoriteCount: number
  wrongCount: number
  studiedBankCount: number
}

export class StatService {
  static summarize(
    progressList: BankProgress[],
    examHistory: ExamHistory[],
    favorites: FavoriteRecord[],
    wrongs: WrongRecord[]
  ): LearningStats {
    let totalAnswered = 0
    let totalCorrect = 0
    for (const p of progressList) {
      totalAnswered += p.finished
      totalCorrect += p.correct
    }
    return {
      totalAnswered,
      totalCorrect,
      accuracyPercent: totalAnswered > 0 ? Math.round(totalCorrect / totalAnswered * 100) : 0,
      examCount: examHistory.length,
      favoriteCount: favorites.length,
      wrongCount: wrongs.length,
      studiedBankCount: progressList.length,
    }
  }
}

这段代码的工程价值有三个。

第一,统计口径集中。LearningStatsPage、HomePage、MinePage 都可以调用同一个方法,避免页面各自计算。

第二,空数据有明确兜底。totalAnswered 为 0 时,正确率直接返回 0,不会出现 NaN%。

第三,返回模型是扁平的。页面渲染数字时不需要再理解 BankProgress、ExamHistory、FavoriteRecord 的内部结构,只拿 LearningStats 即可。

如果这个方法写在页面里,短期看少一个文件,长期会导致两个问题:统计逻辑难以复用,页面 UI 树也会混进大量数据遍历代码。ArkUI 的声明式页面应该尽量保持"读状态、调服务、渲染结果"的节奏。

四、概览卡把核心学习状态压缩成四个指标

OverviewCard 是页面的首屏核心。它展示总答题数、正确率、考试次数和已学习题库数。

ts 复制代码
@Builder
OverviewCard() {
  Column({ space: 16 }) {
    Row() {
      Column({ space: 4 }) {
        Text('学习概览')
          .fontSize(Sizes.H2_FONT)
          .fontWeight(FontWeight.Bold)
          .fontColor(Color.White)
        Text('持续积累,掌握更多地方表达')
          .fontSize(Sizes.CAPTION_FONT)
          .fontColor('#E6FFFFFF')
      }
      Blank()
    }

    if (this.useGridLayout()) {
      Row() {
        this.OverviewItem(`${this.stats().totalAnswered}`, '已答题')
        this.OverviewItem(`${this.stats().accuracyPercent}%`, '正确率')
        this.OverviewItem(`${this.stats().examCount}`, '考试次数')
        this.OverviewItem(`${this.stats().studiedBankCount}`, '学习题库')
      }
    } else {
      Column({ space: 12 }) {
        Row({ space: 12 }) {
          this.OverviewItem(`${this.stats().totalAnswered}`, '已答题')
          this.OverviewItem(`${this.stats().accuracyPercent}%`, '正确率')
        }
        Row({ space: 12 }) {
          this.OverviewItem(`${this.stats().examCount}`, '考试次数')
          this.OverviewItem(`${this.stats().studiedBankCount}`, '学习题库')
        }
      }
    }
  }
}

这里的"持续积累"是页面文案层面的学习反馈,不等同于签到连续天数。源码没有在 LearningStatsPage 里读取 checkInStreak,所以不能把它说成"连续打卡天数"。如果产品后续要把连续学习天数接入统计页,应该新增一个受控字段,例如 @StorageLink('checkInStreak') streak: number = 0,并在 UI 上单独标明数据来源。

概览卡采用宽屏横向四项、窄屏两行两列的方式,避免了手机端文字拥挤,也避免了平板端卡片过高。这个判断来自 useGridLayout()。

ts 复制代码
private useGridLayout(): boolean {
  return this.currentBp === 'lg' && this.pageWidth >= 700
}

这个条件没有只依赖断点,也没有只依赖 pageWidth。断点反映应用层判断,页面宽度反映当前窗口实际尺寸。两个条件同时成立时才切换为宽屏布局,更适合 2in1 小窗口、分屏、平板横竖屏切换等场景。

五、数据卡补足收藏、错题和近 5 次均分

概览卡之外,DataSummary 用三张小卡显示收藏题目、错题数和近 5 次均分。

ts 复制代码
@Builder
DataSummary() {
  Row({ space: 12 }) {
    this.DataCard(`${this.stats().favoriteCount}`, '收藏题目', Colors.ACCENT)
    this.DataCard(`${this.stats().wrongCount}`, '错题数', Colors.ERROR)
    this.DataCard(`${StatService.recentAvgScore(this.examHistory)}`, '近5次均分', Colors.SUCCESS)
  }
  .width('100%')
  .padding({ left: Sizes.PADDING_LARGE, right: Sizes.PADDING_LARGE })
}

三个指标的定位不同:

指标 对用户的意义 源码来源
收藏题目 用户主动标记的复习材料 favoriteRecords.length
错题数 当前仍需复习的薄弱题集合 wrongRecords.length
近 5 次均分 最近考试表现趋势 StatService.recentAvgScore

recentAvgScore 的实现没有使用全部历史,而是默认取最近 5 次。

ts 复制代码
static recentAvgScore(examHistory: ExamHistory[], count: number = 5): number {
  if (examHistory.length === 0) return 0
  const recent = examHistory.slice(0, Math.min(count, examHistory.length))
  const sum = recent.reduce((s, e) => s + e.score, 0)
  return Math.round(sum / recent.length)
}

这个方法依赖一个前提:examHistory 最新记录排在数组前面。这个前提由 UserDataManager.addExamHistory 保证。

ts 复制代码
static addExamHistory(records: ExamHistory[], bankId: string, score: number, total: number,
  correct: number, durationSec: number): ExamHistory[] {
  const result: ExamHistory[] = [{
    bankId, score, total, correct, durationSec, finishedAt: nowStr()
  }, ...records]
  UserDataManager.persist(UserDataManager.K_EXAM, result)
  return result
}

如果后续有人把考试历史改成尾部追加,recentAvgScore 的口径就会被破坏。这类约定应该写在服务方法注释或测试里:考试历史数组按时间倒序排列,slice(0, count) 表示最近 N 次。

六、题库进度卡把总览落回每个题库

全局统计只是入口,真正能指导复习的是每个题库的完成度。LearningStatsPage 遍历 BANKS,为每个题库生成一张进度卡。

ts 复制代码
ForEach(BANKS, (bank: Bank) => {
  this.BankProgressItem(bank)
}, (bank: Bank) => bank.id)

BankProgressItem 负责展示题库封面、题库名称、已完成题数、进度条、正确率和状态标签。

ts 复制代码
@Builder
BankProgressItem(bank: Bank) {
  Row({ space: 12 }) {
    Image(bank.cover)
      .width(58)
      .height(58)
      .borderRadius(12)
      .objectFit(ImageFit.Cover)

    Column({ space: 8 }) {
      Row() {
        Text(bank.name)
          .fontSize(Sizes.BODY_FONT)
          .fontWeight(FontWeight.Bold)
          .fontColor(Colors.TEXT_PRIMARY)
          .maxLines(1)
          .textOverflow({ overflow: TextOverflow.Ellipsis })
        Blank()
        Text(`${StatService.bankFinished(this.progressList, bank.id)}/${bank.totalCount}`)
          .fontSize(Sizes.CAPTION_FONT)
          .fontColor(Colors.TEXT_HINT)
      }

      ProgressBar({
        ratio: bank.totalCount > 0
          ? StatService.bankFinished(this.progressList, bank.id) / bank.totalCount
          : 0,
        height_: 6
      })

      Row() {
        Text(`正确率 ${StatService.bankAccuracy(this.progressList, bank.id)}%`)
          .fontSize(Sizes.SMALL_FONT)
          .fontColor(Colors.PRIMARY)
        Blank()
        Text(this.progressLabel(bank))
          .fontSize(Sizes.SMALL_FONT)
          .fontColor(Colors.TEXT_HINT)
      }
    }
    .layoutWeight(1)
  }
}

这段 UI 里有几个细节值得保留。

第一,题库名称使用 maxLines(1) 和 textOverflow。题库名变长时不会挤掉右侧完成数。

第二,进度条比例有除零保护。bank.totalCount > 0 才计算比例,否则回落到 0。

第三,正确率通过 StatService.bankAccuracy 获取,不在页面里重复找题库进度。

ts 复制代码
static bankAccuracy(progressList: BankProgress[], bankId: string): number {
  const p = progressList.find(r => r.bankId === bankId)
  if (!p || p.finished === 0) return 0
  return Math.round(p.correct / p.finished * 100)
}

static bankFinished(progressList: BankProgress[], bankId: string): number {
  const p = progressList.find(r => r.bankId === bankId)
  return p ? p.finished : 0
}

如果题库从未练习过,正确率显示 0%,完成数显示 0/totalCount。这比显示空白更稳定,也便于用户理解"还没开始"。

七、进度状态标签避免用户只看百分比

progressLabel 是一个小方法,但它解决了统计页常见的表达问题。百分比适合工程计算,不一定适合用户理解。状态标签把进度转换成"未开始、学习中、已完成"。

ts 复制代码
private progressLabel(bank: Bank): string {
  const finished = StatService.bankFinished(this.progressList, bank.id)
  if (finished === 0) return '未开始'
  if (finished >= bank.totalCount) return '已完成'
  return '学习中'
}

这段代码有两个边界。

条件 标签 用户理解
finished === 0 未开始 该题库还没有学习记录
finished >= bank.totalCount 已完成 已答题数达到或超过题库总量
其他情况 学习中 已开始但未完成

这里没有试图判断"薄弱类型"。因为 BankProgress 只保存题库级别的已答和答对数量,不保存按 Question.type 聚合后的错误分布。错题数量来自 wrongRecords.length,但它只包含题目 ID 和题库 ID。

ts 复制代码
export interface WrongRecord {
  questionId: string
  bankId: string
  wrongAt: string
}

如果要真正实现"薄弱类型",需要把错题记录与题库题目列表关联,按题型统计错误数量,再计算占比。例如可以新增一个统计服务方法:

ts 复制代码
interface WeakTypeStat {
  type: string
  wrongCount: number
}

static weakTypes(wrongs: WrongRecord[], questions: Question[]): WeakTypeStat[] {
  const counter = new Map<string, number>()
  for (const wrong of wrongs) {
    const q = questions.find(item => item.id === wrong.questionId)
    if (!q) continue
    counter.set(q.type, (counter.get(q.type) ?? 0) + 1)
  }
  return Array.from(counter.entries())
    .map(([type, wrongCount]) => ({ type, wrongCount }))
    .sort((a, b) => b.wrongCount - a.wrongCount)
}

这段示例不是当前页面已上线能力,而是从现有数据结构自然扩展出来的方向。文章写到这里必须把边界说清楚:当前 LearningStatsPage 还没有调用这个方法,也没有渲染薄弱类型列表。

八、宽屏三列布局让题库进度更适合多设备

HarmonyOS 应用如果只按手机宽度写页面,在平板和 2in1 上会显得松散。LearningStatsPage 对题库进度区域做了两套布局:宽屏使用 Flex 三列,窄屏使用 Column 单列。

ts 复制代码
if (this.useGridLayout()) {
  Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.SpaceBetween }) {
    ForEach(BANKS, (bank: Bank) => {
      Column() {
        this.BankProgressItem(bank)
      }
      .width('32%')
      .margin({ bottom: 10 })
    }, (bank: Bank) => bank.id)
  }
  .padding({ left: Sizes.PADDING_LARGE, right: Sizes.PADDING_LARGE })
} else {
  Column({ space: 10 }) {
    ForEach(BANKS, (bank: Bank) => {
      this.BankProgressItem(bank)
    }, (bank: Bank) => bank.id)
  }
  .padding({ left: Sizes.PADDING_LARGE, right: Sizes.PADDING_LARGE })
}

width('32%') 留出了列间间距,FlexWrap.Wrap 允许题库数量变化时自动换行。对学习统计这种信息密集页面来说,这比固定两列或固定宽度更稳。

页面还通过 onAreaChange 更新宽度:

ts 复制代码
.onAreaChange((oldArea: Area, newArea: Area) => {
  const width = Number(newArea.width)
  if (width > 0) {
    this.pageWidth = width
  }
})

这个处理能覆盖窗口尺寸变化。用户在 2in1 设备上拖动窗口、平板横竖屏切换、应用分屏时,页面宽度会重新进入布局判断。这里需要注意一点:onAreaChange 只更新页面宽度,不直接修改 currentBreakpoint。断点系统仍由全局逻辑负责,本页面只消费断点。

九、结构拆分:页面、统计服务、持久化和题库目录各自独立

统计页的结构可以用下面这张图理解。

四个角色的职责如下。

角色 文件 职责
页面层 LearningStatsPage.ets 读取状态、选择布局、渲染统计 UI
统计服务 StatService.ets 汇总总答题数、正确率、考试次数、收藏数、错题数
持久化服务 UserDataManager.ets 从 Preferences 初始化 AppStorage,并在答题、考试、收藏、错题变化时持久化
题库目录 MockBanks.ets 提供题库基础信息、封面、总题数和章节目录

这套拆分有一个现实好处:当统计口径变化时,通常只需要改 StatService;当 UI 适配变化时,只需要改 LearningStatsPage;当数据持久化变化时,优先改 UserDataManager;当题库增加时,改 BANKS 即可。

如果把这些逻辑都堆到页面里,会出现三个维护风险:

风险 具体表现 更稳的做法
UI 树变长 Builder 中混入大量 reduce/filter 提取到服务方法
多页面口径不一致 首页、统计页、我的页各算各的 统一调用 StatService.summarize
存储细节泄漏 页面直接读写 Preferences key 通过 UserDataManager 管理

这也是 HarmonyOS ArkTS 项目里比较推荐的方向:页面保留短生命周期状态和渲染逻辑,业务口径放服务层,持久化放数据管理层。

十、从练习页到统计页的数据闭环

统计页本身不写入学习数据。它依赖练习页和考试结果页把数据写好。例如练习结束时,PracticePage 会更新题库进度。

ts 复制代码
const correctCount = this.records.filter(r => r.correct).length
this.progressList = UserDataManager.updateProgress(
  this.progressList, this.bankId, this.records.length, correctCount, this.chapterId)

UserDataManager.updateProgress 会按题库 ID 找到已有进度,累加已答题数和答对题数。

ts 复制代码
static updateProgress(records: BankProgress[], bankId: string, addFinished: number, addCorrect: number, chapterId: string): BankProgress[] {
  const idx = records.findIndex(r => r.bankId === bankId)
  let next: BankProgress[]
  if (idx >= 0) {
    const old = records[idx]
    const updated: BankProgress = {
      bankId,
      finished: old.finished + addFinished,
      correct: old.correct + addCorrect,
      lastChapterId: chapterId,
      updatedAt: nowStr(),
    }
    next = [...records]
    next[idx] = updated
  } else {
    next = [{ bankId, finished: addFinished, correct: addCorrect, lastChapterId: chapterId, updatedAt: nowStr() }, ...records]
  }
  UserDataManager.persist(UserDataManager.K_PROGRESS, next)
  return next
}

这个闭环可以描述为:

  1. 用户在练习页提交答案。
  2. 练习页生成 AnswerRecord[]。
  3. 练习页计算本次答对数量。
  4. UserDataManager.updateProgress 写入 bankProgress。
  5. 统计页通过 @StorageLink('bankProgress') 读取新进度。
  6. StatService.summarize 重新计算总答题数和正确率。

这条链路完全在本地完成,不依赖网络,也没有上传用户学习记录。对于一个本地英语学习应用来说,这个边界更容易通过隐私和上架审核,也更容易解释给用户。

十一、页面状态和空数据的处理方式

LearningStatsPage 没有单独写 empty 分支。它的空数据策略是让数字自然回落到 0,让题库列表仍然显示所有题库。这种设计适合学习统计页,因为即使用户没有学习记录,也应该能看到有哪些题库可以开始。

关键兜底如下:

场景 源码兜底
没有任何学习进度 totalAnswered = 0,accuracyPercent = 0
没有考试历史 recentAvgScore 返回 0
某题库没有进度 bankFinished 返回 0,bankAccuracy 返回 0
某题库总题数为 0 进度条 ratio 返回 0

这类统计页不一定需要大面积空状态插画。用户需要看到"还没有数据"之外,还需要知道下一步可以学哪些题库。把题库列表保留出来,比直接显示一个空白页更实用。

如果后续要增强空数据体验,可以在概览卡里增加轻量提示,而不是替换整个页面:

ts 复制代码
if (this.stats().totalAnswered === 0) {
  Text('完成一次练习后,这里会自动生成正确率和题库进度。')
    .fontSize(Sizes.CAPTION_FONT)
    .fontColor('#E6FFFFFF')
}

这段是建议扩展,不是当前源码已实现能力。扩展时仍应保留题库列表,让用户能直接选择学习目标。

十二、验证这条统计链路时看哪些点

本地统计页的验证不应该只看页面能不能打开。至少要覆盖数据写入、统计汇总、布局切换和边界数据。

验证项 操作 期望结果
空数据首进 清空进度、考试、收藏和错题 概览数字为 0,题库列表仍显示
完成一次练习 在任一题库答题并结束 总答题数增加,正确率按答对数量更新
完成一次考试 提交模拟考试并进入结果页 考试次数增加,近 5 次均分更新
添加收藏和错题 在练习页收藏题目、答错题目 收藏题目和错题数增加
宽屏布局 平板横屏或 2in1 宽窗口打开 题库进度使用三列布局
窄屏布局 手机竖屏打开 概览卡两行两列,题库列表单列

调试时可以先看 AppStorage 数据是否存在,再看 StatService 返回值。不要一开始就怀疑 UI。

ts 复制代码
private stats() {
  return StatService.summarize(this.progressList, this.examHistory, this.favRecords, this.wrongRecords)
}

如果 progressList 本身为空,UI 不可能凭空显示已答题数。需要回到练习页提交链路,检查 UserDataManager.updateProgress 是否被调用,以及 Preferences 是否成功 flushSync()。

十三、常见问题与修复方向

问题 可能原因 排查方式 修复方向
正确率一直是 0 finished 没有写入或为 0 查看 bankProgress 是否更新 检查练习结束时 updateProgress 参数
近 5 次均分不变 examHistory 没有新增 查看考试结果页是否调用 addExamHistory 确认考试完成后进入结果页
宽屏仍然单列 currentBp 不是 lg 或页面宽度小于 700 打印断点和 pageWidth 检查断点系统和窗口宽度
题库卡片标题被挤压 长名称缺少省略处理 看 Text(bank.name) 样式 保留 maxLines 与 textOverflow
统计页显示旧数据 AppStorage 没有同步或页面未重新进入 检查数据写入后状态是否回传 保持 @StorageLink 绑定,不复制成局部数组

这里最容易被忽略的是"复制状态"。如果进入页面时把 progressList 复制到一个 @State displayProgressList,后续全局数据变化就可能不同步。当前源码直接使用 @StorageLink,能减少这类问题。

十四、可扩展但不能伪装成已实现的能力

学习统计页后续可以继续扩展,但扩展点要和现有数据结构匹配。

想扩展的能力 当前是否已实现 最小改造
连续打卡天数 不在 LearningStatsPage 中 读取 checkInStreak 并单独渲染
薄弱题型排序 未渲染 关联 WrongRecord 与题目列表,按 Question.type 聚合
学习趋势折线图 未实现 从 ExamHistory.finishedAt 和 score 生成时间序列
章节级完成度 未在本页展示 接入 chapterProgress 并按题库展开
云端同步统计 未实现 需要网络、账号、隐私声明和冲突合并策略

当前文章不声称这些已经上线。源码已经具备题库进度、错题、收藏、考试历史等基础数据,后续要加趋势和薄弱项是合理方向,但需要新增方法和 UI,而不是在文案里直接把 wrongRecords.length 说成薄弱类型分析。

十五、总结:统计页要先统一口径,再谈图表

句匠的学习统计页给出的工程经验很直接:不要急着堆图表,先把数据来源和统计口径统一。LearningStatsPage 通过 @StorageLink 读取全局学习数据,StatService 负责汇总总答题数、正确率、考试次数、收藏数、错题数和已学习题库数,UserDataManager 负责 Preferences 持久化,BANKS 提供题库目录和总题数。

这条链路的可复核点包括:

  • StatService.summarize 统一全局统计口径。
  • recentAvgScore 默认取近 5 次考试。
  • bankFinished 和 bankAccuracy 为每个题库提供进度数据。
  • progressLabel 把完成度转换为未开始、学习中、已完成。
  • useGridLayout 结合断点和页面宽度切换单双栏布局。
  • UserDataManager.updateProgress 与 addExamHistory 形成练习和考试数据闭环。

对于 HarmonyOS 5.0 以上 ArkTS 应用,这种结构比"页面里直接 reduce 一堆数组"更稳。后续无论增加连续打卡、薄弱题型、趋势图还是章节完成度,都能沿着现有的页面层、统计服务层和持久化层继续扩展。

相关推荐
youyin5 天前
HarmonyOS ArkUI 组件与自定义组件零基础:从搭页面到组件化开发
华为·harmonyos
HwJack205 天前
【HarmonyOS开发小实践】ArkTS 从 TypeScript 到方舟语言的演进
华为·harmonyos
威哥爱编程6 天前
HarmonyOS 7 星盾机密风控实战:设备风险因子端侧融合计算,可用不可见
harmonyos·arkts
威哥爱编程6 天前
HarmonyOS 7 应用快启实战:关键资源预加载进内存,冷启告别白屏
华为·harmonyos·arkts
马剑威(威哥爱编程)6 天前
【共创稿事节】HarmonyOS 7 视觉 AI 进阶实战:人脸检测 + 通用文字识别(OCR)两步接入
华为·harmonyos·arkts
HMS Core6 天前
HarmonyOS智慧多窗,让应用在任意窗口都“恰到好处”
harmonyos
梦想不只是梦与想6 天前
鸿蒙 应用发布准备工作(一)
harmonyos·鸿蒙上架·鸿蒙应用发布
马剑威(威哥爱编程)6 天前
【共创稿事节】HarmonyOS 7 文搜图实战:自然语言检索本地图片,全流程端侧闭环
华为·harmonyos
李游Leo6 天前
HarmonyOS 7 HAR + HSP 工程化实战:模块复用、包体积控制与依赖边界
harmonyos