HarmonyOS问卷引擎连载08:多端统计快照与版本一致性刷新【共创稿事节】
课程评价工具从条件跳题、规则拓扑和草稿迁移开始,经过动态必填、跨页缺答、评分摘要,已经可以生成符合最小样本策略的公开统计投影。收尾时常见的问题不在公式,而在两个屏幕:教师在平板上查看同一门课程的结果,学生在手机上打开统计页。平板还保留上次汇总修订27,手机已经取得修订28。如果两个终端各自拿本地结果重新聚合,除了平均分不同,样本不足班级的隐藏状态也可能发生偏差。
最后一篇把报表快照作为不可拆分的展示数据单元。每份快照固定问卷版本、统计修订、最小样本策略修订与公开行集合;手机和平板只决定如何排版,不能各自重新计算分组均值,更不能在布局切换时绕过上一节的公开投影。这也是把“结果显示一致”落实到数据层的关键步骤。

在公开统计投影外再加一层快照身份
上一篇的GroupAggregator将有效提交去重、版本过滤、分组和计算,ResultProjection依据最小样本数得到公开行。这里沿用两个模块,不让页面触碰个人填写记录。新增加的model/ReportSnapshot.ets、service/ReportCache.ets、service/ReportRepository.ets和pages/ResponsiveReportPage.ets分别负责数据契约、版本收敛、缓存协调和布局。
报表身份至少需要四个维度:问卷版本、统计窗口、展示策略修订和数据修订。假设问卷版本为3、统计窗口为本学期、策略仍按不少于五份展示,服务端在新提交进入汇总后把报表修订从27提高到28。终端只接收与当前查询身份相同、且修订更新的完整快照。若策略从五份调到八份,即便数据修订数未变,也必须重新获取经过新策略投影的结果,不能使用旧快照继续展示。
// model/ReportSnapshot.ets
export interface PublicGroupRow {
groupId: string;
canShow: boolean;
meanText: string;
histogram: number[];
label: string;
}
export interface ReportSnapshot {
surveyVersion: number;
windowKey: string;
policyRevision: number;
revision: number;
generatedAtMs: number;
rows: PublicGroupRow[];
}
export function queryIdentity(s: ReportSnapshot): string {
return `${s.surveyVersion}:${s.windowKey}:${s.policyRevision}`;
}
generatedAtMs用于向读者说明数据生成时间,不用作修订排序的唯一依据。两个节点机器时间可能存在偏差,而服务端给同一查询身份分配的单调修订更适合做版本判断。rows只保留已完成公开投影的组别结果,不应该把原始问卷正文、提交ID或具体填写者信息塞进缓存里。对样本不足的组,canShow=false对应meanText='—'和空直方图,所有终端拿到相同的隐藏语义。
版本比较必须在整份快照上执行
常见的刷新写法会把返回行逐条覆盖进当前列表,这容易让A班已经显示新均值、B班仍处于旧阈值状态。更稳妥的方法是先校验整份结果,再在同一处替换快照引用。校验覆盖基本字段、组ID唯一性、评分字段及被隐藏组的敏感投影。后端应承担权威校验,客户端增加必要的格式守卫,避免数据异常直接进入界面。
// service/ReportCache.ets
import { PublicGroupRow, ReportSnapshot, queryIdentity } from '../model/ReportSnapshot';
export function validPublicSnapshot(s: ReportSnapshot): boolean {
if (!Number.isInteger(s.revision) || s.revision < 0 ||
!Number.isInteger(s.policyRevision) || s.policyRevision < 1 ||
!Number.isInteger(s.surveyVersion) || s.surveyVersion < 1 ||
s.windowKey.length === 0 || !Array.isArray(s.rows)) {
return false;
}
const ids = new Set<string>();
for (const row of s.rows) {
if (!row.groupId || ids.has(row.groupId)) return false;
ids.add(row.groupId);
if (!row.canShow &&
(row.meanText !== '—' || row.histogram.length > 0)) return false;
if (row.canShow && row.meanText.length === 0) return false;
}
return true;
}
export function chooseSnapshot(current: ReportSnapshot | null,
incoming: ReportSnapshot): ReportSnapshot {
if (!validPublicSnapshot(incoming)) throw new Error('报表字段无效');
if (current === null) return incoming;
if (queryIdentity(current) !== queryIdentity(incoming)) {
throw new Error('统计口径变化,请切换查询');
}
return incoming.revision > current.revision ? incoming : current;
}
数据不应只比较revision大小而忽略查询身份。假设教师从课程甲切换到课程乙,乙课程最新修订可能是8,甲课程为28。数字较小并不表示乙课程更旧,它们属于不同的查询。仓库切换查询时应创建新的缓存槽位,并且清空当前页面的选择状态;旧请求即使返回,也必须核对当前windowKey及页面查询代次,不能写到新的课程结果上。
还有一种冲突容易被忽略:服务端将报告修订设置为28,但客户端缓存中已有同为28、内容不同的两份结果。相同查询身份和相同修订应对应同一份不可变投影;遇到这种情况,仓库应记录摘要差异并触发受控重取,而不是随机采用最后到达的数据。若需要快速比较,可由服务端在响应中提供计算好的内容摘要和策略版本标识。
缓存只保存已脱敏的公开快照
Preferences可用于保存体积有限的公开统计结果,在应用再次进入统计页时减少空白等待。缓存命名以问卷版本、统计窗口及策略修订进行隔离,避免旧策略下被展示的数值在新版策略中短暂露出。对人数不足的组,持久化之前就应完成结果投影;界面上的遮挡图层不能替代对缓存内容的处理。
缓存加载有三个状态:尚未读取、读取到可解析但可能过期的快照、以及已经与服务端最新修订核对的快照。第二个状态应明确标注“缓存修订27”,不能把它显示成“最新统计”。遇到解析错误、字段缺失或身份不匹配时,应移出本次展示路径并重新读取服务端公开结果,不应使用缓存里的旧均值拼接新策略标签。
// service/ReportRepository.ets
import { preferences } from '@kit.ArkData';
import { common } from '@kit.AbilityKit';
import { ReportSnapshot } from '../model/ReportSnapshot';
import { validPublicSnapshot } from './ReportCache';
export async function savePublicReport(context: common.UIAbilityContext,
cacheKey: string, snapshot: ReportSnapshot): Promise<void> {
if (cacheKey.length === 0 || !validPublicSnapshot(snapshot)) {
throw new Error('公开报表格式不正确');
}
const store = await preferences.getPreferences(context,
{ name: 'survey_report_cache' });
await store.put(cacheKey, JSON.stringify(snapshot));
await store.flush();
}
上面的保存函数只负责持久化已经核对的快照。具体的网络仓库还需要处理认证、请求超时、响应顺序和缓存失效。多个刷新请求同时到达时,应在业务仓库内按查询身份串行选择版本,再提交同一份新快照到内存与缓存。若刷新失败,保留旧快照用于阅读,同时展示其修订与数据时间,不把网络失败误写成空样本。
响应式布局不改变数据口径
手机通常采用单列卡片,平板可以让多个班级结果并排。布局只调整信息摆放方式,不能为了大屏图表好看就额外展示B班的分布。ArkUI的GridRow与GridCol支持按照断点分配列宽,@State负责在快照替换时刷新已绑定的视图。这里使用明确的sm、md、lg列配置,让同一份rows适配不同的窗口宽度。
对有十二份有效记录的A班,可以显示4.2的均值和被允许公开的档位分布;只有三份的B班在手机和平板都显示“样本不足”,不输出柱图所需的数值。对大屏布局也要防止侧栏摘要泄漏:如果卡片主体隐藏了B班均值,旁边的排行、全局筛选提示和导出入口也不能再把B班分数带出来。
// pages/ResponsiveReportPage.ets 的统计展示
import { PublicGroupRow } from '../model/ReportSnapshot';
@Entry
@Component
struct ResponsiveReportPage {
@State rows: PublicGroupRow[] = [];
@State revision: number = 0;
@State statusText: string = '等待统计快照';
build() {
Column({ space: 14 }) {
Text('课程评价统计').fontSize(22).fontWeight(FontWeight.Bold)
Text(`报表修订 ${this.revision} · ${this.statusText}`)
GridRow({ columns: { sm: 4, md: 8, lg: 12 }, gutter: 12 }) {
ForEach(this.rows, (row: PublicGroupRow) => {
GridCol({ span: { sm: 4, md: 4, lg: 6 } }) {
Column({ space: 8 }) {
Text(row.groupId).fontSize(18)
if (row.canShow) {
Text(`平均分 ${row.meanText}`).fontSize(24)
} else {
Text('样本不足').fontColor('#A66B26')
}
}.padding(14).width('100%')
}
}, (row: PublicGroupRow) => row.groupId)
}.width('100%')
}.padding(16).width('100%')
}
}


该页面的数据接口应返回PublicGroupRow[],而不是ScoreRecord[]。移动端和平板端可以共享查询参数、报表身份、修订与公开投影;布局断点变化只驱动组件重新排列。对于折叠屏,还要考虑展开与折叠期间窗口宽度连续变化、文本放大与横竖屏切换,尽量让内容随容器重排,避免依赖设备名称硬编码尺寸。
把异常分成口径、网络与展示三类
统计页“平均分突然变动”时,先检查是否切换了问卷版本、统计窗口或阈值策略,再比较报表修订;若口径相同,查看是否有新有效提交进入服务端汇总。“刷新后结果回退”应检查网络响应乱序是否绕过chooseSnapshot();“B班在平板露出分布”则应追到公开投影是否仍携带隐藏组直方图,不能只在组件里加入条件分支后停止核对。
建议用固定输入集合检查快照替换:修订27更新至28应整体切换,较晚到达的修订26不能覆盖28;同修订重复结果应保持稳定;策略修订变化后旧缓存应失效;缓存JSON缺少policyRevision时需要重新获取;B班样本数为三时所有公开布局都只能展示占位文案。日志记录查询身份、前后修订、公开行数及隐藏组数即可,避免保存具体答卷或单人评分。
华为开发者资料可参照栅格布局适配示例、GridCol组件参考、跨设备栅格规则与用户首选项API。栅格组件与首选项属于HarmonyOS通用开发接口;快照修订、公开阈值和缓存身份是应用的数据协议,接入时要以部署的SDK和后台契约为准。
至此,这套问卷引擎将填写、提交、评分、匿名统计和多端展示串成完整链路。真正重要的不是让所有屏幕看起来一样,而是使每台设备上的同一份公开报告遵循同样的版本、阈值与字段范围:数据先收敛,组件再适配;展示层始终不接触应该被隐藏的原始结果。
更多推荐

所有评论(0)