【口算王|09】HarmonyOS ArkTS 学习统计实战:计算连续训练和正确率趋势

学习统计页面最容易出现一种"数字都对,但结论不成立"的问题:累计答题数确实来自真实记录,正确率公式也没有写错,页面却把这些累计值包装成"连续学习""近期趋势"或"能力提升"。累计值只能说明到目前为止发生了多少次答题,无法回答用户连续训练了几天,也无法告诉用户正确率是在上升还是下降。

连续训练需要按自然日保存活动证据;正确率趋势需要多个时间窗口的答题分子和分母。若数据模型只保留每个题库的累计 finished、累计 correct 和最后更新时间,即使界面画出一条折线,也无法从累计终值还原每天的变化。

本文基于口算王项目 D:\huawei\one16-11 的真实源码,复核 LearningStatsPage.etsStatService.etsUserDataManager.etsPracticePage.etsExamResultPage.etsEntryAbility.ets。包名 com.jiaweikang.one16 是本文草稿核验使用的唯一标记。当前实现真实支持累计答题、累计正确率、挑战次数、训练题库数、收藏数、错题数、近 5 次挑战均分和各题库进度;连续训练天数与正确率趋势尚未实现,文中将其作为基于现有数据链路的增强方案,不把设计代码描述成现成功能。

本文会完成四个工程判断:

  • 复核当前每个统计数字的真实来源和计算公式;
  • 说明累计正确率、近 5 次均分与正确率趋势的差异;
  • 设计能够计算连续训练天数和日正确率趋势的数据模型;
  • 给出 ArkTS 聚合算法、持久化边界、多设备布局与测试矩阵。

一、统计页读取的是四组 AppStorage 数据

LearningStatsPage.ets 没有自己读取文件,也没有制造演示数字。页面通过 @StorageLink 订阅四组应用级状态:

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

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

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

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

这四组状态分别承担不同统计口径:

状态 页面用途 是否包含时间序列
bankProgress 累计答题、累计答对、题库进度 只保留每个题库最后更新时间
examHistory 挑战次数、近 5 次均分 每次挑战都有完成时间
favoriteRecords 当前收藏数量 有收藏时间,但不是训练证据
wrongRecords 当前错题数量 有最近错误时间,但会去重

页面把计算交给 StatService

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

这是一个合理的职责边界。ArkUI 页面负责展示和响应式排列,统计公式集中在基础特性层。首页和"我的"页面也调用同一个 summarize(),从而避免同一个"正确率"在三个页面出现三种算法。

二、数据在应用启动时从 Preferences 恢复

EntryAbility 启动后调用 UserDataManager.init(this.context)。管理器使用 ArkData Preferences 打开名为 daily_math_drill 的本地存储,并把 JSON 字符串恢复到 AppStorage

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

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

因此统计页展示的是本机持久化数据,不是网络账户数据,也不是云端跨设备同步结果。用户卸载应用、清除应用数据或在另一台设备安装时,这些统计不会自动迁移。

当前初始化用一个大 try/catch 处理解析失败。任何一项 JSON 异常都可能进入兜底分支,把多组 AppStorage 设为空数组。更细致的做法是逐项解析并校验结构,使某个设置损坏时不影响全部学习记录:

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

统计可信度不仅取决于公式,也取决于数据恢复是否完整。页面显示 0 时,应能区分"用户没有训练"和"持久化记录解析失败"。

三、累计答题和累计正确率如何计算

StatService.summarize() 遍历每个 BankProgress,把 finishedcorrect 相加:

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

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

const accuracyPercent = totalAnswered > 0
  ? Math.round(
      totalCorrect / totalAnswered * 100
    )
  : 0

这里使用的是加权总体正确率,不是"各题库正确率的平均值"。假设一年级答了 100 题、正确 90 题,二年级答了 10 题、正确 5 题:

text 复制代码
总体正确率 = (90 + 5) / (100 + 10)
           = 86.36%
           ≈ 86%

如果先计算 90%50% 再取平均,会得到 70%,小样本题库被赋予了与大样本题库相同权重。当前实现按总答对数除以总答题数,口径更适合"全部训练正确率"。

零答题保护也很重要。没有答题时返回 0,避免 NaN 或除零结果进入 UI:

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

0% 同时可能代表"尚未答题"和"答过但全部错误"。如果产品需要区分这两个状态,统计结果应增加 hasTrainingData,页面在无数据时显示"暂无记录",不要让新用户误以为自己正确率为零。

四、BankProgress 是累计快照,不是答题流水

当前进度模型如下:

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

一次练习结束后,UserDataManager.updateProgress() 会找到同题库记录,把本次答题数和答对数累加:

ts 复制代码
const updated: BankProgress = {
  bankId,
  finished: old.finished + addFinished,
  correct: old.correct + addCorrect,
  lastChapterId: chapterId,
  updatedAt: nowStr()
}

这种结构非常适合快速展示累计值。页面不需要扫描几千条答题流水,就能立即获得题库已答题数和正确率。

它的限制同样明确:旧的 updatedAt 会被覆盖。假设用户周一答 20 题、周二答 30 题,最终只留下累计 50 题和周二的更新时间。仅凭这条记录,无法还原周一、周二分别答了多少题,也无法证明训练连续发生在两个自然日。

因此当前 BankProgress 不能直接计算连续训练天数,也不能生成日正确率趋势。要保留历史,必须新增会追加的会话记录或按日汇总记录。

五、挑战次数和近 5 次均分来自 ExamHistory

挑战结果使用另一组数据:

ts 复制代码
export interface ExamHistory {
  bankId: string
  score: number
  total: number
  correct: number
  durationSec: number
  finishedAt: string
}

ExamResultPage 出现后调用 addExamHistory(),新记录放在数组头部:

ts 复制代码
const result: ExamHistory[] = [{
  bankId,
  score,
  total,
  correct,
  durationSec,
  finishedAt: nowStr()
}, ...records]

因此 recentAvgScore() 直接取数组前 N 项:

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(
    (value, exam) => value + exam.score,
    0
  )
  return Math.round(sum / recent.length)
}

这里存在一个隐含契约:examHistory 必须始终按时间倒序。当前写入方法通过前插维持了顺序;如果以后导入备份、合并云端数据或执行迁移,最好在计算前按可解析时间排序,不要继续依赖数组位置。

还要区分"近 5 次均分"和"正确率趋势"。均分只有一个数字,它告诉用户最近五次挑战的平均表现;趋势需要至少两个有时间顺序的点,才能判断变化方向。当前统计页没有绘制趋势图,也没有比较前后窗口。

六、各题库进度和正确率使用独立分母

统计页逐个遍历 BANKS,对每个题库调用:

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

static bankAccuracy(
  progressList: BankProgress[],
  bankId: string
): number {
  const progress = progressList.find(
    item => item.bankId === bankId
  )
  if (!progress || progress.finished === 0) {
    return 0
  }
  return Math.round(
    progress.correct /
    progress.finished * 100
  )
}

卡片展示 finished / bank.totalCount、正确率和"未开始/学习中/已完成"标签。标签规则是:

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 是累计答题次数,并不一定是"去重后完成题目数"。用户重复训练同一批题时,累计次数可能超过题库题目总数,页面就会显示类似 120/100。标签会进入"已完成",但分数文本仍可能超过总量。

如果业务想表达"累计练习次数",就不应拿它除以题库唯一题目数;如果想表达"题库完成进度",则需要保存已完成题目的唯一 ID 集合。两个指标不能共用一个字段名称。

七、studiedBankCount 的真实口径

summarize() 直接使用:

ts 复制代码
studiedBankCount: progressList.length

在当前写入逻辑中,updateProgress() 只有完成练习后才创建记录,所以数组长度通常等于"有训练记录的题库数"。但从数据健壮性看,更准确的算法应过滤 finished > 0,并去重 bankId

ts 复制代码
const studiedBankIds = new Set<string>()

progressList.forEach((progress: BankProgress) => {
  if (progress.finished > 0) {
    studiedBankIds.add(progress.bankId)
  }
})

const studiedBankCount = studiedBankIds.size

这样即使迁移数据包含零进度项或重复项,统计也不会虚高。文章和发布说明应称其为"训练题库数"或"已学习题库数",而不是"训练套数已完成",因为有一题记录也会计入。

八、连续训练需要新增日维度证据

要计算连续天数,至少需要知道用户在哪些自然日完成过有效训练。一个可复用的数据模型是训练会话:

ts 复制代码
export interface TrainingSession {
  sessionId: string
  bankId: string
  answered: number
  correct: number
  startedAt: number
  finishedAt: number
  mode: 'practice' | 'exam' | 'wrong'
}

每次完成练习时追加一条记录。连续训练只关心有效会话的日期集合,不关心同一天训练几次:

ts 复制代码
function localDateKey(timestamp: number): string {
  const date = new Date(timestamp)
  const year = date.getFullYear()
  const month = String(
    date.getMonth() + 1
  ).padStart(2, '0')
  const day = String(
    date.getDate()
  ).padStart(2, '0')
  return `${year}-${month}-${day}`
}

这里使用本地日期,而不是直接截取 UTC 格式。中国时区凌晨训练时,toISOString().slice(0, 10) 可能落到前一天,导致连续天数计算错误。

随后把有效训练日去重:

ts 复制代码
function trainingDateKeys(
  sessions: TrainingSession[]
): string[] {
  const keys = sessions
    .filter((session: TrainingSession) =>
      session.answered > 0
    )
    .map((session: TrainingSession) =>
      localDateKey(session.finishedAt)
    )

  return Array.from(new Set(keys)).sort()
}

是否把"只打开页面""收藏一道题""查看错题"算作训练,需要产品先定义。更稳的口径是至少提交一道答案,避免用户仅启动应用就增加连续天数。

九、连续天数算法必须明确"截至今天"还是"最近一段"

连续训练通常有两种口径:

  1. 当前连续:必须今天或昨天有训练,从最近有效日向前计算;
  2. 历史最长连续:扫描全部日期,找到最长相邻区间。

计算当前连续天数时,可以先建立日期集合,再从今天向前:

ts 复制代码
function previousLocalDay(date: Date): Date {
  return new Date(
    date.getFullYear(),
    date.getMonth(),
    date.getDate() - 1
  )
}

function currentStreak(
  sessions: TrainingSession[],
  now: Date = new Date()
): number {
  const keys = new Set(
    trainingDateKeys(sessions)
  )

  let cursor = new Date(
    now.getFullYear(),
    now.getMonth(),
    now.getDate()
  )

  if (!keys.has(localDateKey(cursor.getTime()))) {
    cursor = previousLocalDay(cursor)
  }

  let streak = 0
  while (keys.has(localDateKey(cursor.getTime()))) {
    streak += 1
    cursor = previousLocalDay(cursor)
  }
  return streak
}

这段代码允许"今天尚未训练,但昨天之前连续"仍显示当前连续天数。若产品要求当天未训练立即归零,可以删除回退到昨天的分支。规则没有绝对对错,但必须写进验收用例。

跨月、跨年和闰日不要用毫秒数简单减 24 * 60 * 60 * 1000 后直接格式化。通过本地日历构造前一天,能降低夏令时地区的日期偏移风险。

十、正确率趋势需要保留分子和分母

趋势点不能只保存百分比。一天答 1 题全对是 100%,一天答 100 题对 90 题是 90%,如果直接平均百分比会得到 95%,却忽略样本量。

按日聚合时应保存:

ts 复制代码
export interface DailyAccuracyPoint {
  date: string
  answered: number
  correct: number
  accuracyPercent: number
}

从会话记录计算最近七天:

ts 复制代码
function buildDailyAccuracy(
  sessions: TrainingSession[]
): DailyAccuracyPoint[] {
  const map = new Map<string, {
    answered: number
    correct: number
  }>()

  sessions.forEach((session: TrainingSession) => {
    if (session.answered <= 0) return

    const key = localDateKey(session.finishedAt)
    const old = map.get(key) || {
      answered: 0,
      correct: 0
    }
    map.set(key, {
      answered: old.answered + session.answered,
      correct: old.correct + session.correct
    })
  })

  return Array.from(map.entries())
    .sort((a, b) => a[0].localeCompare(b[0]))
    .map(([date, value]) => ({
      date,
      answered: value.answered,
      correct: value.correct,
      accuracyPercent:
        value.answered > 0
          ? Math.round(
              value.correct /
              value.answered * 100
            )
          : 0
    }))
}

图表点由真实分子、分母计算。Tooltip 可以同时显示"86%,18/21",用户能理解样本量;后续修改四舍五入规则时,也不必丢弃历史。

十一、如何判断趋势,而不是凭肉眼描述

只有两个点时,最后一天比前一天高,并不代表稳定提升。可以比较两个时间窗口的加权正确率:

ts 复制代码
function weightedAccuracy(
  points: DailyAccuracyPoint[]
): number {
  const answered = points.reduce(
    (sum, point) => sum + point.answered,
    0
  )
  const correct = points.reduce(
    (sum, point) => sum + point.correct,
    0
  )

  return answered > 0
    ? correct / answered * 100
    : 0
}

function trendDelta(
  points: DailyAccuracyPoint[]
): number {
  if (points.length < 4) return 0

  const middle = Math.floor(points.length / 2)
  const previous = weightedAccuracy(
    points.slice(0, middle)
  )
  const recent = weightedAccuracy(
    points.slice(middle)
  )

  return Math.round(recent - previous)
}

产品还应设置最小样本量。例如最近窗口少于 20 道题时,只展示趋势线,不输出"提升明显"之类结论。统计结论必须与样本量绑定,不能因为一天答对一道题就宣称能力跃升。

当前源码没有上述趋势判断,也没有趋势结论文案。它只显示累计正确率和近 5 次挑战均分。这一边界在技术文章、应用介绍和上架材料中都应保持一致。

十二、持久化会话时要控制数据规模

Preferences 适合轻量设置和小规模记录。若长期保存每次训练会话,数组会不断增长,序列化和同步写入成本也会增加。可以采用"近期会话 + 日汇总"策略:

ts 复制代码
export interface DailyTrainingSummary {
  date: string
  answered: number
  correct: number
  sessionCount: number
  activeBankIds: string[]
}

每次训练结束只更新当天汇总:

ts 复制代码
function mergeDailySummary(
  records: DailyTrainingSummary[],
  session: TrainingSession
): DailyTrainingSummary[] {
  const date = localDateKey(session.finishedAt)
  const index = records.findIndex(
    item => item.date === date
  )

  const old = index >= 0
    ? records[index]
    : {
        date,
        answered: 0,
        correct: 0,
        sessionCount: 0,
        activeBankIds: []
      }

  const next: DailyTrainingSummary = {
    date,
    answered: old.answered + session.answered,
    correct: old.correct + session.correct,
    sessionCount: old.sessionCount + 1,
    activeBankIds: Array.from(new Set([
      ...old.activeBankIds,
      session.bankId
    ]))
  }

  const result = [...records]
  if (index >= 0) result[index] = next
  else result.push(next)
  return result
}

如果只需要最近 90 天趋势,可在持久化前裁剪更早记录。若要支持多年历史、复杂查询或数据迁移,RDB 比一个不断增长的 Preferences JSON 更合适。

十三、统计页当前没有折线图

LearningStatsPage 的真实 UI 由以下部分组成:

  • 渐变概览卡;
  • 三张数据摘要卡;
  • "各年级训练进度"标题;
  • 六个题库进度项;
  • 每个题库的进度条、正确率和状态标签。

页面没有 Canvas 折线图、柱状图或按日列表。"正确率趋势"目前是文章中的增强设计,不是现有页面截图可验证的能力。

如果新增趋势图,优先保证可读性:

  • 横轴使用最近 7 天或 14 天,避免标签拥挤;
  • 无训练日显示断点还是 0%,必须定义清楚;
  • 数据点提供答题数和答对数;
  • 深色模式下坐标、折线和提示文字保持对比度;
  • 屏幕阅读场景提供文本摘要,不只依赖颜色;
  • 图表为空时显示"完成一次训练后生成趋势"。

无训练日通常不应当作 0%。它代表没有样本,而不是全部答错。折线可以断开,也可以只连接有效训练日,但不能擅自补零。

十四、响应式布局已有窄屏和宽屏分支

页面通过两个条件决定布局:

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

currentBp 来自 AppStoragepageWidth 通过根容器 onAreaChange 更新。宽屏概览四项横排,题库卡按 32% 宽度形成三列;窄屏概览改为两行两列,题库卡纵向排列。

双条件比只看设备类型更稳。平板进入小窗口后,即使设备断点仍偏大,只要实际宽度不足 700,就不会强行塞三列。

新增趋势图时也应复用实际容器宽度:

ts 复制代码
private chartHeight(): number {
  return this.useGridLayout() ? 280 : 220
}

宽度由父容器约束,Canvas 或图表组件不要使用屏幕固定宽度。窗口缩放、横竖屏切换后,应重新计算绘制区域,防止点位和文字重叠。

十五、常见统计异常与排查顺序

现象 优先检查 真实原因或修复
新用户显示正确率 0% totalAnswered 是否为 0 用"暂无记录"区分无数据
总正确率与题库平均不同 是否采用加权口径 总答对数除以总答题数
近 5 次均分选错记录 examHistory 是否倒序 写入前插或计算前排序
进度出现 120/100 finished 是否累计次数 与唯一完成题数拆成两个指标
连续天数始终为 1 是否只保留最新 updatedAt 新增日汇总或会话流水
凌晨训练落到前一天 是否截取 UTC 日期 使用本地日历日期键
无训练日显示 0% 是否把无样本当错误 使用缺失点或断线
趋势被少量题目放大 是否忽略样本量 保留 answered/correct 并设阈值
清理一项后统计全空 初始化是否整体 catch 逐项解析、逐项兜底
平板小窗卡片拥挤 是否只看设备断点 同时检查实际页面宽度

排查时先记录原始数组长度和聚合分子、分母,不要只看最终百分比。显示 86% 并不能证明输入记录正确。

十六、可执行测试矩阵

统计服务适合用纯数据做单元测试:

ts 复制代码
const progress: BankProgress[] = [
  {
    bankId: 'b_grade1',
    finished: 100,
    correct: 90,
    lastChapterId: 'c1',
    updatedAt: '2026-07-20 10:00'
  },
  {
    bankId: 'b_grade2',
    finished: 10,
    correct: 5,
    lastChapterId: 'c2',
    updatedAt: '2026-07-21 10:00'
  }
]

const stats = StatService.summarize(
  progress,
  [],
  [],
  []
)

// totalAnswered = 110
// totalCorrect = 95
// accuracyPercent = 86

连续训练增强至少覆盖:

用例 日期集合 预期
无记录 0 天
今天训练 今天 1 天
今天、昨天 连续两日 2 天
今天、前天 中间断一天 1 天
昨天、前天 今天未训练 按产品规则为 2 或 0
跨月 7 月 31 日、8 月 1 日 2 天
跨年 12 月 31 日、1 月 1 日 2 天
同日多次 三次同一天 1 天

趋势测试要覆盖:

  • 零答题日不参与正确率除法;
  • 同一天多会话正确合并;
  • 每个点保留 answeredcorrect
  • 时间排序与输入数组顺序无关;
  • 最近窗口和上一窗口使用加权正确率;
  • 样本不足时不输出提升或下降结论。

十七、发布前复核清单

面向 HarmonyOS 5.0 及以上环境,可以按以下清单验收:

  • UserDataManager.init() 在页面创建前完成;
  • Preferences 恢复后四组 AppStorage 数据与本地记录一致;
  • 累计正确率使用总答对数除以总答题数;
  • 无数据状态不与真实 0% 混淆;
  • 近 5 次均分只基于真实挑战历史;
  • 挑战历史顺序契约明确;
  • 题库"累计答题次数"和"唯一完成进度"没有混用;
  • 收藏数、错题数描述为当前集合数量;
  • 当前页面未宣称连续训练或趋势图已经实现;
  • 若新增连续训练,使用本地自然日并定义今天未训练的规则;
  • 若新增趋势,保存每日答题分子和分母;
  • 无训练日不会伪造为 0%
  • 宽屏三列、窄屏单列和概览两行布局无溢出;
  • 深浅色、长题库名、零数据和超大累计值仍可读。

总结

学习统计的可信度来自数据口径,而不是卡片数量。当前口算王源码通过 Preferences、AppStorage、UserDataManagerStatService 建立了完整的本地累计统计链路:练习结束累加题库进度,挑战完成追加历史,统计页展示累计答题、加权正确率、近 5 次均分及各题库进度。

这套模型适合回答"到目前为止答了多少、总体正确率多少",却不足以回答"连续训练了几天、正确率如何变化"。要补齐后两项,需要新增按日汇总或训练会话,保留自然日、答题数和答对数,再用明确的日期规则和加权窗口计算。只有原始证据、聚合公式和页面文案三者一致,学习统计才是可复核的工程功能,而不是看起来漂亮的数字。

本文由 AI 辅助整理,所有现有功能边界、代码路径与统计口径均依据项目真实源码复核。

相关推荐
lqj_本人1 小时前
Flutter 鸿蒙实战:用 wakelock_plus 三方库给阅读页加上屏幕常亮
flutter·华为·harmonyos
2601_9622959911 小时前
湖南码界领航教育科技有限公司:Python数据分析进阶,统计与可视化
numpy·pandas·统计·数据可视化·python数据分析
aqi0012 小时前
鸿蒙版本的JSBridge兼容与安卓配套的H5啦
android·华为·harmonyos·鸿蒙·移动应用
熊猫钓鱼>_>13 小时前
Flutter app_settings 鸿蒙适配实战:Intent 体系到 Want 的跨越
flutter·华为·harmonyos·openharmony·intent·want
贾伟康16 小时前
【句匠|20】HarmonyOS ArkTS AppGallery 发布复查实战:核对包名、版本、设备、素材和离线声明
harmonyos·arkts·应用上架·appgallery·发布审核
贾伟康16 小时前
【句匠|15】HarmonyOS ArkTS 本地状态持久化实战:让保存、删除和页面返回后的数据即时一致
harmonyos·arkts·数据持久化·appstorage·preferences
贾伟康19 小时前
【句匠|16】HarmonyOS ArkTS 多设备布局实战:适配手机、平板和 PC/2in1 的窗口变化
harmonyos·arkts·arkui·响应式布局·多设备适配
小雨青年19 小时前
【HarmonyOS 7 平行视界深度实战】03 购物模式怎么实现连续浏览和左右推挤
华为·harmonyos
imgsq21 小时前
S-57 数据解剖:把一个 ENC 文件拆给你看
c++·数据可视化