ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

灯光模拟HarmonyOS应用实战-87-同样练一场,理论题占20条、科三只占1条:用RetentionClass分层保留历史

2026/9/11 19:40:42 拓冰建站 浏览量
灯光模拟HarmonyOS应用实战-87-同样练一场,理论题占20条、科三只占1条:用RetentionClass分层保留历史 灯光模拟HarmonyOS应用实战-87-同样练一场理论题占20条、科三只占1条用RetentionClass分层保留历史一份历史列表设置 100 条上限看起来已经控制了容量但“条”是否代表同一种学习事件决定了这 100 个位置是否公平。The_kemusan当前在理论页每答一题就写一条记录而灯光模拟和科三实操在整场结束时各写一条。若用户沿着“20 题测试”连续作答 20 次理论路径会消耗 20 个历史位置完成一场灯光或实操只消耗 1 个。所有记录随后进入同一个exam_history数组并统一截取最新 100 条。这不是说当前代码已经丢失某位用户的数据。没有设备数据就无法知道真实使用比例和淘汰结果。本文从源码确认生产粒度不一致再推导全局行数策略可能产生的偏置最后给出RetentionClass、保留单元、动态底线和淘汰回执的建议设计。一、三条写入路径的“1条”并不是同一种单位理论作答位于Index.ets:1266-1279。用户选中答案后页面取得当前题、判断正误并立即调用addSimpleRecord()。正确或错误都会写入所以每一次有效作答对应一次写请求。灯光模拟和科三实操不同。Index.ets:1522-1543中finishLightExam()与finishPracticalExam()只在整场结束时调用同一个写入函数并把累计分数、总题数和整场耗时交给记录。// 当前理论路径每次作答写一条this.addSimpleRecord(question.subject,this.activeMode,correct,correct?答对${question.title}:答错${question.title},correct?:question.id,correct?:correctText,correct?:selectedText);// 当前实操路径整场完成后写一条this.addSimpleRecord(SUBJECT_THREE,科三实操灯光,passed,reason,wrongQuestionId,correctOption,selectedOption,this.correctCount,this.totalQuestions,durationSeconds);addSimpleRecord()在Index.ets:1614-1633把二者都构造成PracticeRecord。当前模型没有“这条记录代表单题还是整场”的保留字段也没有理论会话 ID。因而存储层看到的只是若干普通数组元素无法按学习事件粒度保留。二、全局100条策略会按写入频率分配空间PracticeStore.ets:5-7定义单一HISTORY_KEY exam_history和MAX_HISTORY_COUNT 100PracticeStore.ets:54-60先读取全部记录把新记录放在头部再执行slice(0, 100)asyncaddRecord(record:PracticeRecord):PromisePracticeRecord[]{constrecordsawaitthis.listRecords();records.unshift(record);constnextRecordsrecords.slice(0,MAX_HISTORY_COUNT);awaitthis.putString(HISTORY_KEY,JSON.stringify(nextRecords));returnnextRecords;}这段逻辑忠实实现了“只留最新 100 行”。它没有数组越界也没有把新记录放错位置。风险来自计量单位高频、细粒度的生产者自然获得更多行低频、粗粒度的生产者更容易被挤出时间窗口。下面的数字是推演样例不是用户日志使用序列写入行数对100行池的占用理论入口作答20次2020%完成1场灯光模拟11%完成1场科三实操11%连续5组、每组理论作答20次100100%连续5场科三实操55%当前源码还没有完整的理论会话对象因此表中的“组”只是把连续 20 次作答作为对比单位不能冒充系统已经保存的场次。它说明的是slice(0, 100)按数组行计数不按用户完成的一次练习计数。三、本文与第15、39、63篇分别停在哪条边界同一组源码可以暴露多个问题边界不清就会重复写。三篇既有文章与本文的分工如下篇目已解决的核心本文不再展开第15篇Preferences 读写、旧字段兼容、最新100条parse、normalize、flush 基础链路第39篇20题有限会话、终局、汇总尾题推进和结果页第63篇RecordKind区分单题结论与整场合格历史卡文案与记录语义第87篇不同记录粒度如何共享有限保留空间RetentionClass、保留单元与淘汰回执RecordKind可以成为保留分类的输入却不能自动得出策略。例如同为整场记录的灯光模拟和科三实操是否共享底线仍是产品决策理论单题是否按会话成组也需要sessionId。本文不重写第 63 篇的展示文案也不靠模式字符串重新猜记录种类。四、RetentionClass表达资源策略不替代业务种类建议让记录在创建时获得稳定kind再显式映射到保留分类。分类名称描述“如何竞争存储空间”而不是“页面显示什么文案”。两者分开后产品可以在不改历史卡标题的情况下调整保留策略。enumRecordKind{QUESTION_ATTEMPTquestion_attempt,LIGHT_EXAMlight_exam,PRACTICAL_EXAMpractical_exam,UNKNOWN_LEGACYunknown_legacy}enumRetentionClass{THEORY_ATTEMPTtheory_attempt,LIGHT_SESSIONlight_session,PRACTICAL_SESSIONpractical_session,LEGACYlegacy}functionresolveRetentionClass(kind:RecordKind):RetentionClass{if(kindRecordKind.QUESTION_ATTEMPT){returnRetentionClass.THEORY_ATTEMPT;}if(kindRecordKind.LIGHT_EXAM){returnRetentionClass.LIGHT_SESSION;}if(kindRecordKind.PRACTICAL_EXAM){returnRetentionClass.PRACTICAL_SESSION;}returnRetentionClass.LEGACY;}这是建议模型当前PracticeRecord没有这些字段。旧数据也不能仅凭score total或mode文案强制归类无法证明的旧记录应进入LEGACY先保留中性策略。等发布迁移报告确认旧模式值的实际分布后再决定哪些映射可信。五、理论单题应先组成保留单元如果策略直接按行分类理论仍然可能在临界位置留下半个会话同一组 20 次作答只保留后 7 条复盘时看不到开头。更稳妥的做法是给新理论流程生成sessionId让一组单题尝试形成一个RetentionUnit。灯光与实操本来一场一条各自也是一个单元。interfaceRetentionUnit{unitId:string;retentionClass:RetentionClass;newestAt:number;records:PracticeRecord[];}functiongetRetentionUnitId(record:PracticeRecord,kind:RecordKind,sessionId:string):string{if(kindRecordKind.QUESTION_ATTEMPTsessionId.length0){returntheory:${sessionId};}returnrecord:${record.id};}完整保留理论单元会带来新的权衡一组有 20 行而剩余空间可能只有 7 行。策略必须明确是“放弃整个旧单元”“允许拆分并标记不完整”还是“先保存会话汇总、再淘汰明细”。第 39 篇提出的TheorySessionSummary可以降低整组明细被删除后的信息损失但本文不假定它已实现。当前旧记录没有sessionId。迁移时不能根据相邻时间自动断言它们属于同一场因为用户可能中途离开再返回。保守做法是旧记录一条一个单元并在回执中计入unknownGroupCount。六、动态底线比固定分区更能利用100个位置把 100 个位置永久切成 50、25、25 会浪费空闲分类用户从未练科三时那 25 个位置不该一直空着。建议使用“可满足时的最低保留量 全局按新近程度回填”每个分类先从最新单元开始选择最多占用其最低行数某分类不足最低量时只取现有数据不制造空位剩余容量在所有未选单元中按newestAt回填若要求整组保留只有整个单元放得下时才加入容量无法容纳任何剩余整组时停止并说明原因。interfaceRetentionClassRule{retentionClass:RetentionClass;minimumRows:number;maximumRows:number;keepUnitWhole:boolean;}interfaceRetentionPolicy{policyId:string;totalRows:number;rules:RetentionClassRule[];}constexamplePolicy:RetentionPolicy{policyId:history-retention-v1,totalRows:100,rules:[{retentionClass:RetentionClass.THEORY_ATTEMPT,minimumRows:40,maximumRows:70,keepUnitWhole:true},{retentionClass:RetentionClass.LIGHT_SESSION,minimumRows:15,maximumRows:40,keepUnitWhole:true},{retentionClass:RetentionClass.PRACTICAL_SESSION,minimumRows:15,maximumRows:40,keepUnitWhole:true},{retentionClass:RetentionClass.LEGACY,minimumRows:5,maximumRows:20,keepUnitWhole:false}]};表中的 40、15、15、5 只是便于讨论的示例不能直接当作发布参数。真实数值应依据匿名聚合的使用频率、产品复盘目标和设备存储实验决定。minimumRows总和也必须小于或等于totalRowsmaximumRows不能小于对应底线。七、选择算法要确定、可重复且不依赖数组偶然顺序保留器的输入先按单元最新时间倒序并用稳定unitId处理同毫秒并列。底线阶段与回填阶段都走同一个“是否容纳”函数避免一条路径允许拆组、另一条路径却静默截断。下面保留核心接口具体容器写法要按项目 ArkTS 约束调整。interfaceRetentionDecision{kept:RetentionUnit[];dropped:RetentionUnit[];usedRows:number;}functioncanTakeUnit(unit:RetentionUnit,usedRows:number,rowLimit:number):boolean{returnusedRowsunit.records.lengthrowLimit;}functioncountRows(units:RetentionUnit[]):number{letcount0;for(letindex0;indexunits.length;index){countunits[index].records.length;}returncount;}实现时还要防止同一unitId被底线阶段和回填阶段选两次。最简单的方法是维护已选 ID 集合若目标 ArkTS 版本对Set的使用有限制可以用字符串键数组和小规模线性查找先保证行为清楚再根据实际数据量优化。最终写回前应把选中单元内的记录重新按createdAt倒序并确认不超过 100 行。不能先slice(0, 100)再分类因为那样低频分类已经在策略运行前被淘汰。八、淘汰回执要回答“为什么留下这些记录”没有回执时策略调整后的历史数量变化很难排查。RetentionDecisionReceipt不需要复制题干、答案或失败原因只保存政策版本、各类计数、整组决策和固定问题码。interfaceRetentionClassCount{retentionClass:RetentionClass;beforeRows:number;keptRows:number;droppedRows:number;keptUnits:number;droppedUnits:number;}interfaceRetentionDecisionReceipt{policyId:string;beforeRows:number;afterRows:number;unknownGroupCount:number;classCounts:RetentionClassCount[];issues:string[];}建议问题码使用固定枚举例如UNIT_TOO_LARGE、UNKNOWN_LEGACY_KIND、MINIMUM_NOT_REACHED。日志只写计数和内部 ID避免把reason、题干、选项内容重复输出。回执是策略证据不是写盘成功证据put与flush是否完成仍要由存储提交结果表达。九、用冲突样本验证公平性和确定性验证数据要故意制造不平衡而不是三类各放十条。一个有区分力的样例可以包含80 条理论单题按 4 个会话分组15 场灯光15 场实操10 条无法归类的旧记录总计 120 行。然后以示例政策运行两次断言输出不超过 100 行灯光、实操在数据充足时分别达到配置底线同一理论sessionId不被拆开输入顺序打乱后只要时间与 ID 相同输出 ID 集合一致新增一条最新实操记录只影响可解释的最旧候选没有记录对象同时出现在 kept 与 dropped回执各类 before 等于输入总数各类 kept 加 dropped 等于 before。functionassertReceiptBalanced(receipt:RetentionDecisionReceipt):void{letbefore0;letafter0;for(letindex0;indexreceipt.classCounts.length;index){constitemreceipt.classCounts[index];if(item.keptRowsitem.droppedRows!item.beforeRows){thrownewError(class count mismatch);}beforeitem.beforeRows;afteritem.keptRows;}if(before!receipt.beforeRows||after!receipt.afterRows||after100){thrownewError(retention receipt mismatch);}}这类纯函数用例能证明算法约束不能证明 Preferences 已落盘也不能证明历史页面按预期展示。后两层仍需要存储集成验证和页面交互核对。十、排障表、验证清单与未验证边界症状优先检查可能原因处理方向科三历史仍很快消失各类 before/kept 计数先执行了全局slice先分类选择最后统一排序理论会话只剩后半段unitId 与keepUnitWhole单题未带 sessionId新写入补会话键旧记录保守处理某分类长期占满全部空间maximumRows只设置底线没有上限配置可解释的类上限有空位却不再保留被拒绝单元行数剩余空间放不下整组保留会话摘要或明确允许拆组同一输入两次结果不同时间并列排序缺稳定 ID 次序createdAt后再按unitId旧记录被错误归为理论迁移依据用1/1猜种类回退LEGACY并记录未知数页面显示数量与回执不符写盘提交结果回执生成后存储失败分开策略回执与提交回执发布前清单如下新记录在生产端写入可靠的RecordKind。新理论流程提供稳定且一次会话内不变的sessionId。保留分类与页面展示文案相互独立。旧记录无法确认种类时进入LEGACY不强行猜测。各类底线总和不超过 100类上限不低于类底线。底线不足时剩余空间可被其他分类使用。整组保留失败时有固定问题码和可解释回执。最终写回仍按时间倒序且总行数不超过 100。策略运行在全局截断之前。纯函数、Preferences 集成、页面复盘分别验收。当前源码能够确认理论每次有效作答调用一次addSimpleRecord()灯光模拟与科三实操在整场结束时各调用一次所有记录进入同一个PracticeStore新增后统一保留最新 100 行。由此可以推导按写入频率分配空间的风险但不能断言某个设备已经淘汰了哪一条记录也不能从源码确定最合适的分类配额。RetentionClass、sessionId、动态底线、整组选择器和淘汰回执都是建议实现尚未合入The_kemusan。本次没有修改工程源码没有运行hvigorw assembleHap --no-daemon没有生成新的 HAP也没有在模拟器或真机上制造 120 行样本。第 15 篇的存储基础、第 39 篇的理论会话和第 63 篇的记录种类可作为前置能力只有完成实现、迁移样本审查、存储集成与设备复盘后才能评价新的保留策略。