【中国方言题库|11】HarmonyOS ArkTS 学习统计实战:计算地区学习进度与收藏数量

摘要:中国方言题库的学习统计页没有维护一套额外计数器,而是从 bankProgressexamHistoryfavoriteRecordswrongRecords 四组本地事实数据实时汇总。StatService 负责全局答题数、加权正确率、考试次数、收藏数、错题数和已学习题库数,页面再按题库展示完成量与正确率,并在大屏断点下切换为三列布局。本文面向 HarmonyOS 5.0 及以上版本,基于真实 ArkTS 源码复核统计公式、Preferences 到 AppStorage 的数据链、近五次平均分、进度条钳制和多设备布局,同时指出累计答题次数可能超过题库题量等真实语义边界。

一、统计页应消费事实,不应手工同步计数器

统计类页面最常见的错误,是每完成一次动作就同时修改多个计数器:

text 复制代码
答题记录 +1
总答题数 +1
题库答题数 +1
今日答题数 +1

任何一步遗漏都会产生漂移。中国方言题库采用更简单的路线:答题流程只更新题库进度、考试历史、收藏和错题等事实集合,统计页按需汇总。

唯一校验标记:统计值从事实集合推导,不再维护第二套手工计数器

二、真实源码分为页面、统计服务和持久化三层

本文复核:

text 复制代码
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
entry/src/main/ets/common/components/ProgressBar.ets
entry/src/main/ets/entryability/EntryAbility.ets

职责分工是:

text 复制代码
UserDataManager
  -> 读取和持久化本地事实
AppStorage / @StorageLink
  -> 在页面间同步事实数组
StatService
  -> 执行纯统计计算
LearningStatsPage
  -> 按断点渲染概览和题库进度

页面没有直接访问 Preferences,也没有在 Builder 中复制一套持久化逻辑。

三、四组事实数据分别代表什么

页面声明:

ts 复制代码
@StorageLink('bankProgress')
progressList: BankProgress[] = []

@StorageLink('examHistory')
examHistory: ExamHistory[] = []

@StorageLink('favoriteRecords')
favRecords: FavoriteRecord[] = []

@StorageLink('wrongRecords')
wrongRecords: WrongRecord[] = []

四组数据语义不同:

数据 代表事实 统计用途
BankProgress[] 每个题库累计答题与答对量 总答题、总正确、题库进度
ExamHistory[] 每次考试结算快照 考试次数、近五次均分
FavoriteRecord[] 当前收藏的问题 收藏数量
WrongRecord[] 当前仍在错题集的问题 错题数量

"收藏数"和"错题数"是当前集合长度,不是历史上累计收藏过或答错过多少次。

四、Preferences 如何进入 AppStorage

应用启动时,EntryAbility.onCreate() 调用:

ts 复制代码
UserDataManager.init(this.context)

UserDataManager 从名为 dialect_quiz 的 Preferences 读取 JSON 字符串,然后初始化:

ts 复制代码
AppStorage.setOrCreate<FavoriteRecord[]>(
  'favoriteRecords',
  JSON.parse(favStr) as FavoriteRecord[]
)

AppStorage.setOrCreate<WrongRecord[]>(
  'wrongRecords',
  JSON.parse(wrongStr) as WrongRecord[]
)

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

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

统计页通过 @StorageLink 消费这些值。数据修改后,相关页面能观察到新数组,不需要再次打开数据库。

五、BankProgress 是累计尝试量,不是去重题目数

模型定义:

ts 复制代码
export interface BankProgress {
  bankId: string
  finished: number
  correct: number
  lastChapterId: string
  updatedAt: string
}

更新时执行累加:

ts 复制代码
finished: old.finished + addFinished
correct: old.correct + addCorrect

因此 finished 表示累计完成的答题次数。用户重复练习同一道题,计数仍会增加。它不是"题库中已经去重掌握的题目数量"。

标题中"学习进度"在当前实现里的准确含义是"累计答题量相对题库题量的展示进度",不能把它当成严格的知识点完成率。

六、StatService 返回统一 LearningStats

统计结果接口:

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

页面通过:

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

获得同一份统计口径。首页、我的页面也复用 StatService.summarize(),避免多个页面各算各的。

七、总答题数和总正确数如何计算

实现为:

ts 复制代码
let totalAnswered = 0
let totalCorrect = 0

for (const p of progressList) {
  totalAnswered += p.finished
  totalCorrect += p.correct
}

这是一种按题库进度聚合的累计量。如果四川话完成 30 次、粤语完成 20 次:

text 复制代码
totalAnswered = 30 + 20 = 50

它不依赖题库总数,也不扫描全部题目,计算复杂度为:

text 复制代码
O(progressList.length)

八、全局正确率是加权正确率

公式:

ts 复制代码
accuracyPercent:
  totalAnswered > 0
    ? Math.round(
        totalCorrect / totalAnswered * 100
      )
    : 0

它先汇总所有正确数和答题数,再计算比例。假设:

text 复制代码
四川话:1/1,正确率 100%
粤语:50/100,正确率 50%

全局正确率是:

text 复制代码
(1 + 50) / (1 + 100) = 50.5% -> 50%

而不是:

text 复制代码
(100% + 50%) / 2 = 75%

前者按真实答题样本加权,更符合"所有答题的总体正确率"。

九、零答题保护避免 NaN

totalAnswered === 0 时,服务返回 0

ts 复制代码
totalAnswered > 0
  ? Math.round(totalCorrect / totalAnswered * 100)
  : 0

因此新用户看到 0%,不会出现 NaN%Infinity%。统计服务统一处理后,多个页面无需重复防御。

仍需注意损坏数据,例如 correct > finished 会产生超过 100% 的正确率。当前 Preferences 数据来自内部更新路径,正常不会出现;如增加导入恢复,应在数据层校验。

十、考试次数为何直接取历史长度

服务返回:

ts 复制代码
examCount: examHistory.length

每进入一次结果页,UserDataManager.addExamHistory() 会追加一条记录。因此页面展示的是本地历史条数。

如果结果页生命周期重复触发导致同一场考试重复写入,考试次数也会被放大。统计服务不会主动去重,因为 ExamHistory 没有 sessionId。要解决,应在写入源头增加幂等键,而不是在统计页猜哪些记录重复。

十一、收藏数量是当前收藏集合长度

统计:

ts 复制代码
favoriteCount: favorites.length

收藏模型:

ts 复制代码
export interface FavoriteRecord {
  questionId: string
  bankId: string
  createdAt: string
}

toggleFavorite() 找到同一 questionId 时移除,否则在数组头部新增。项目题目 ID 带题库前缀,因此当前集合能表达跨题库收藏。

这不是"收藏操作次数"。用户收藏后取消,数量会回落。

十二、错题数量代表当前待复习项

服务:

ts 复制代码
wrongCount: wrongs.length

添加错题时先过滤同题,再新增:

ts 复制代码
const filtered = records.filter(
  r => r.questionId !== questionId
)
const result = [
  { questionId, bankId, wrongAt: nowStr() },
  ...filtered
]

因此同一道题答错多次仍只占一条。在错题练习中答对后,页面调用 removeWrong() 删除它。

所以"错题数"更接近当前错题本中的待复习数量,而不是历史错误次数。

十三、已学习题库数的真实口径

服务使用:

ts 复制代码
studiedBankCount: progressList.length

只要某题库有一条 BankProgress 就被计入。正常更新路径首次答题才创建记录,因此通常等于有学习行为的题库数。

但它没有检查:

ts 复制代码
p.finished > 0

也没有验证 bankId 是否仍存在于 BANKS。如果导入了零进度或旧版本残留题库,数量可能偏大。更严格的实现是:

ts 复制代码
const validBankIds = new Set(
  BANKS.map(bank => bank.id)
)

const studiedBankCount = progressList.filter(
  p =>
    p.finished > 0 &&
    validBankIds.has(p.bankId)
).length

当前源码尚未采用这种过滤。

十四、近五次平均分依赖历史顺序

recentAvgScore()

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)
}

UserDataManager.addExamHistory() 把新记录放在数组头部,所以 slice(0, 5) 确实得到最近五次。

若未来从文件导入历史,必须先保证顺序,或者按 finishedAt 排序;否则"近五次"只代表数组前五条。

十五、题库正确率如何处理未开始状态

实现:

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
  )
}

没有进度或答题数为零时显示 0%。页面同时使用 progressLabel() 显示"未开始",因此用户能区分"未开始的 0%"和"已经答题但全错的 0%"吗?

当前两者文案不同:

text 复制代码
未开始:进度标签"未开始",正确率 0%
全错:进度标签"学习中",正确率 0%

这组组合足以表达差异。

十六、每个题库的已答题数来自 bankId

服务:

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

页面对 BANKS 中每个题库调用该方法。题库模型含 regionId,当前六个题库分别对应四川、粤语、东北、上海、闽南、客家等地区或方言分支,因此页面按题库展示,也形成了地区学习分布。

但源码标题是"各题库学习进度",并没有直接按 Region 聚合多个题库。若未来一个地区下有多个题库,需要额外按 regionId 汇总。

十七、题库总题量来自真实题目目录

BANKS 初始 totalCount 为 0,随后 syncCatalogCounts() 遍历实际题目:

ts 复制代码
for (const bank of BANKS) {
  const questions = getQuestions(bank.id)
  bank.totalCount = questions.length
}

所以页面分母:

ts 复制代码
bank.totalCount

来自当前题库真实题目数量,不是手写宣传数字。

十八、累计答题可能超过题库总题量

页面展示:

ts 复制代码
`${finished}/${bank.totalCount}`

由于 finished 是累计尝试次数,用户反复练习后可能出现:

text 复制代码
320/300

progressLabel() 会在:

ts 复制代码
finished >= bank.totalCount

时显示"已完成"。这在视觉上表示学习量已覆盖总题量阈值,但不能证明 300 道不同题都做过。

若产品需要去重完成度,应保存已答 questionId 集合,不能从累计次数推导。

十九、进度条会把 ratio 钳制到 0 到 1

页面传入:

ts 复制代码
ratio:
  bank.totalCount > 0
    ? finished / bank.totalCount
    : 0

公共 ProgressBar 再执行:

ts 复制代码
Math.max(
  0,
  Math.min(1, this.ratio)
)

因此即使 finished > totalCount,前景宽度最多 100%,不会溢出容器。数字仍会显示真实累计值,不会被静默改写。

二十、统计概览由四个核心指标组成

概览卡显示:

text 复制代码
已答题
正确率
考试次数
学习题库

紧凑布局使用两行两列;大屏布局使用一行四列。指标值全部来自 StatService.summarize(),没有写死演示数字。

页面每次调用 this.stats() 都重新汇总。当前数组很小,开销有限;如果历史达到数千条,可在状态变化时生成一次 ViewModel,避免一次构建重复计算。

二十一、数据摘要卡展示收藏、错题和均分

DataSummary()

ts 复制代码
this.DataCard(
  `${this.stats().favoriteCount}`,
  '收藏题目',
  Colors.ACCENT
)

this.DataCard(
  `${this.stats().wrongCount}`,
  '错题数',
  Colors.ERROR
)

this.DataCard(
  `${StatService.recentAvgScore(
    this.examHistory
  )}`,
  '近5次均分',
  Colors.SUCCESS
)

三张卡片使用不同语义色,但标签文字仍明确说明含义,不只依靠颜色区分。长标签通过单行省略保护布局。

二十二、大屏为何同时检查断点和宽度

布局判断:

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

既要求断点为 lg,又要求实际页面宽度至少 700。这样可以避免断点状态滞后或容器嵌套导致页面实际宽度不足时仍强行三列。

pageWidth 通过根容器:

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

实时更新,支持窗口缩放。

二十三、三列题库卡片如何适应宽屏

大屏使用:

ts 复制代码
Flex({
  wrap: FlexWrap.Wrap,
  justifyContent:
    FlexAlign.SpaceBetween
})

每个题库卡外层:

ts 复制代码
.width('32%')
.margin({ bottom: 10 })

一行三列留出间隙,题库数量超过三时自动换行。紧凑模式则用单列 Column

这种适配覆盖 phone、tablet 和 2in1 的基础信息密度变化,没有为每种设备复制页面。

二十四、题库卡的文本溢出保护

题库名称:

ts 复制代码
.maxLines(1)
.textOverflow({
  overflow: TextOverflow.Ellipsis
})

摘要卡标签也有同样保护。题库图固定 58x58,右侧内容使用:

ts 复制代码
.layoutWeight(1)

所以长名称会优先省略,不会把进度数字挤出卡片。宽屏三列下尤其需要这种约束。

二十五、页面为什么全部可滚动

主体放在:

ts 复制代码
Scroll() {
  Column({ space: 18 }) {
    // 概览、摘要、各题库
  }
}
.layoutWeight(1)
.edgeEffect(EdgeEffect.Spring)

手机横屏、小窗或系统字体放大时,所有题库仍可通过滚动访问。底部使用 Blank().height(20) 保留视觉间距。

当前页面没有读取底部导航安全区;它作为普通二级页使用 TopBar,但在手势导航和特殊窗口下仍应实机确认最后一张卡是否有足够避让。

二十六、统计服务为何适合单元测试

StatService 方法:

  • 不访问 UI;
  • 不读 Context;
  • 不直接访问 Preferences;
  • 输入数组,返回数字或对象;
  • 没有异步回调。

因此可以用简单样本验证:

ts 复制代码
const progress: BankProgress[] = [
  {
    bankId: 'b_sichuan',
    finished: 10,
    correct: 8,
    lastChapterId: '',
    updatedAt: ''
  },
  {
    bankId: 'b_yue',
    finished: 20,
    correct: 10,
    lastChapterId: '',
    updatedAt: ''
  }
]

预期:

text 复制代码
totalAnswered = 30
totalCorrect = 18
accuracyPercent = 60
studiedBankCount = 2

二十七、近五次均分的边界测试

至少覆盖:

text 复制代码
空历史 -> 0
1 次 80 分 -> 80
3 次 80/90/100 -> 90
6 次 -> 只取数组前 5 条
count=0 -> 当前实现会产生 0/0

最后一个边界很重要。方法默认 count=5,页面不会传 0;但公共 API 若被其他模块调用 count=0recent=[],随后除以零会得到 NaN

可以增加:

ts 复制代码
if (count <= 0) return 0

当前页面路径安全,但服务契约仍有加固空间。

二十八、数据损坏时应在哪一层处理

UserDataManager.init() 把所有读取放在一个 try/catch 中。任意 JSON 解析失败会回退初始化多组空数组。这样能避免启动崩溃,但可能因为一个字段损坏而丢失其他可用统计。

更细粒度的策略是逐键解析:

ts 复制代码
function parseArray<T>(
  raw: string,
  fallback: T[]
): T[] {
  try {
    const value = JSON.parse(raw) as T[]
    return Array.isArray(value)
      ? value
      : fallback
  } catch (_) {
    return fallback
  }
}

统计页只消费已归一化的数据,不应在 UI 层到处判断 JSON 错误。

二十九、收藏数量与地区数量如何扩展

标题关注"地区学习进度与收藏数量"。当前收藏只展示全局总数。如果要按地区统计收藏,可利用记录中的 bankId 和题库的 regionId

text 复制代码
FavoriteRecord.bankId
  -> Bank.id
  -> Bank.regionId

再按 regionId 分组。当前每个主要题库对应一个地区,分组结果会接近按题库统计。

扩展时不要直接从题目中文名称解析地区。应使用稳定 ID 映射,避免文案调整破坏统计。

三十、当前统计不是活跃天数或学习时长

页面没有保存每日学习日志,也没有汇总 durationSec 为总学习时长。ExamHistory 只记录考试用时,普通章节和随机练习的 timerSec 没有进入统计服务。

因此现有能力不能描述为:

  • 连续学习天数;
  • 今日学习时长;
  • 总学习小时数;
  • 地区掌握度预测;
  • 用户排名。

这些指标都需要新增真实数据模型和更新链路。

三十一、性能上避免在 build 中多次 summarize

OverviewCard() 中多次调用:

ts 复制代码
this.stats().totalAnswered
this.stats().accuracyPercent
this.stats().examCount
this.stats().studiedBankCount

每次都会重新遍历 progressList。当前只有少量题库,影响可忽略;但代码可读性和可测试性仍可改善。

可在构建前准备:

ts 复制代码
const stats = this.stats()

ArkUI Builder 对普通局部语句有约束时,可以改为缓存 @State ViewModel,并在事实数组变化时更新。不要为了小数组过早引入复杂响应式框架。

三十二、统计测试矩阵

场景 预期
全部数组为空 所有统计为 0,页面不崩溃
单题库 10 答 8 对 总答题 10,正确率 80%
多题库样本不等 使用加权正确率
收藏后取消 收藏数回落
同题重复答错 错题集合只保留一条
错题模式答对 错题数减少
无考试历史 近五次均分 0
超过 5 次考试 只取最新前 5 条
累计答题超过题量 数字真实显示,进度条不溢出
lg 且宽度 >=700 三列题库布局
lg 但宽度不足 保持单列
窗口缩放 页面Width更新并切换布局

三十三、发布前真实性与合规检查

  1. "已答题"明确是累计答题次数;
  2. "学习题库"不等于全部题库完成;
  3. "错题数"是当前错题集,不是历史错误次数;
  4. "收藏题目"是当前收藏集合;
  5. "近5次均分"依赖历史按新到旧排列;
  6. 不宣称学习时长、连续天数、排名或掌握度;
  7. 清除本地数据后统计同步归零;
  8. 深浅色下概览卡与数据卡对比度合格;
  9. 手机、平板、2in1、小窗和横屏布局不截断;
  10. 大字体下题库名称和指标标签不重叠;
  11. Preferences 只保存必要本地学习数据;
  12. 安装、启动、答题、统计更新、重启恢复和卸载流程稳定。

三十四、结语

LearningStatsPage 的工程价值不在于显示了多少张卡,而在于数据口径集中:BankProgress 保存累计答题事实,ExamHistory 保存考试结算,收藏和错题保存当前集合;StatService 用纯函数汇总;页面通过 @StorageLink 响应数据变化,并在大屏条件下切换布局。

同样要承认当前边界:题库"完成量"是累计尝试次数,可能超过真实题量;学习题库数直接取进度数组长度;近五次均分依赖数组顺序;页面没有学习时长、活跃天数或排名。统计可信的前提,不是数字看起来丰富,而是每个数字都能沿着真实记录追溯到计算公式。


AI 辅助声明:本文部分内容由 AI 辅助整理,所有功能描述、代码片段、统计口径和工程结论均依据项目真实源码人工复核;未伪造学习时长、用户排名、收藏量或平台数据。

相关推荐
用户1269550879141 小时前
RK3588 + OpenHarmony 6.1 RKNN2 NPU 验证指南
harmonyos
m0_749690232 小时前
【寻迹校园 HarmonyOS NEXT 实战 35】先写全页面 Design Spec 再写 ArkUI:一个比赛项目的设计稿门禁实践
harmonyos·响应式设计·设计规范·arkui·ui设计
Magic-ZYJ3 小时前
HarmonyOS 日记类 App 的日期设计:本地自然日、月历与夏令时边界
华为·harmonyos·arkts·arkui·问题排查·移动端开发·独立开发者
贾伟康3 小时前
【中国方言题库|12】HarmonyOS ArkTS 题库列表组件实战:减少多地区页面重复并保证点击反馈
harmonyos·arkts·arkui·组件化·多设备适配
梦想不只是梦与想3 小时前
鸿蒙 AGC:华为开放能力管理(四)
harmonyos·agc·开发能力
大锅盖13 小时前
ArkUI声明式范式下的暗夜紫调沉浸式剧本杀组局社区:迷雾粒子双层特效与四套差异化弹框的工程化实践
华为·harmonyos
见山是山-见水是水12 小时前
鸿蒙Divider 分割线组件完全指南:内容分组、视觉分区与自定义样式
华为·harmonyos
贾伟康13 小时前
【知律|18】HarmonyOS ArkTS 权限与隐私实战:让 module.json5、功能说明和拒绝路径一致
harmonyos·arkts·隐私合规·appgallery·应用权限