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

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

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

本文唯一标记:com.jiaweikang.one18。以下内容只基于句匠项目真实源码:entry/src/main/ets/pages/LearningStatsPage.etslibraryb/src/main/ets/utils/StatService.etslibrarya/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 的公共能力层。页面只知道 bankProgressexamHistory 两个全局状态存在,不需要知道它们被保存在名为 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,
    }
  }
}

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

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

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

第三,返回模型是扁平的。页面渲染数字时不需要再理解 BankProgressExamHistoryFavoriteRecord 的内部结构,只拿 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 = 0accuracyPercent = 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) 样式 保留 maxLinestextOverflow
统计页显示旧数据 AppStorage 没有同步或页面未重新进入 检查数据写入后状态是否回传 保持 @StorageLink 绑定,不复制成局部数组

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

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

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

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

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

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

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

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

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

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

相关推荐
贾伟康2 小时前
【口算王|17】HarmonyOS ArkTS 亮暗色与视觉令牌实战:集中颜色、间距和交互状态避免页面割裂
harmonyos·arkts·arkui·深色模式·ui设计
搬砖的kk2 小时前
从 0 到 1:react-native-screenshot-aware 鸿蒙适配实战(RNOH 0.84)
react native·华为·harmonyos
贾伟康2 小时前
【口算王|15】HarmonyOS ArkTS 本地状态持久化实战:让保存、删除和页面返回后的数据即时一致
harmonyos·arkts·数据持久化·状态管理·preferences
贾伟康2 小时前
【口算王|18】HarmonyOS ArkTS 权限与隐私实战:让 module.json5、功能说明和拒绝路径一致
harmonyos·arkts·权限管理·隐私合规·module.json5
ChinaDragonDreamer2 小时前
HarmonyOS:6.0 新增和增强特性
harmonyos·鸿蒙
Georgewu2 小时前
【HarmonyOS AI】 通用文字识别详解
harmonyos
哈__3 小时前
Flutter 3.44.9 + OpenHarmony7:home_widget 三方库桌面服务卡片(FormKit)的应用
flutter·华为·harmonyos
贾伟康3 小时前
【句匠|08】HarmonyOS ArkTS 句库搜索实战:支持关键词、分类和无结果反馈
harmonyos·arkts·分类筛选·学习应用·搜索功能
Sunny_G3 小时前
从 DevEco Code 到 Claude Code:一次工具链切换的完整决策
harmonyos