NetworkKit + HarmonyOS 最近检测记录:最小留存与诊断闭环(连载04/04)【共创稿事节】
“检测成功”四个字并不能回答第二天的排障问题:昨天究竟测过没有,返回的是 HTTP 错误,还是请求根本没到服务器?上一版“链路镜”能在页面上展示结果,却只保存当前一次检测状态。用户离开页面、应用进程被系统回收后,之前的判断依据就没有了。网络诊断工具需要适量的历史信息,但它同样不能变成一份无意识收集网址、凭据和响应内容的日志仓库。
这一次不另起炉灶。项目继续使用 NetworkProbePage.ets、固定目标 https://example.com/、phase 的 idle/loading/ok/error/cancelled 状态,以及前三篇已经实现的 currentId 代次隔离、inFlight 限制和 ProbeAdvice.ets 错误分层。我们只增加一个独立的记录仓库与“最近检测”区域:成功、HTTP 非成功、传输异常和用户主动取消都留下可以理解的结构化摘要。这个收尾的关键不是“保存得越多越好”,而是把可复盘、少留存、写入失败不误报放在同一个约束里。

一、先讨论不该保存什么
一次 HTTPS 请求可能涉及完整 URL、查询参数、重定向、Cookie、认证头与响应正文。诊断页面真正需要的是事件时间、事件类型、HTTP 状态码或者 NetworkKit 传输错误码,以及一个对用户可读的归类信息。把 response.result 或完整请求头原样存进本地偏好文件,看起来能增加“细节”,实际会扩大隐私暴露范围;更糟的是排查报告一旦被用户转发,秘密参数也会跟着流出。
因此“链路镜”只存固定结构 ProbeRecord。字段 kind 表示四种互斥结果,httpCode 与 transportCode 各自负责不同故障层级,at 为本机事件时刻,summary 是通过 describeHttp 或 describeTransport 生成的短句,id 是本地列表稳定键。不保存完整 URL、HTTP 响应体、Cookie、Token、设备标识和原始异常对象。本文只检测预设的 https://example.com/,如果后期允许用户输入目标地址,记录结构仍不应随意增加原始查询参数。
// entry/src/main/ets/model/ProbeRecord.ets
export type ProbeKind = 'ok' | 'http_error' | 'transport_error' | 'cancelled';
export interface ProbeRecord {
id: string;
at: number;
kind: ProbeKind;
httpCode: number;
transportCode: number;
summary: string;
}
export function createRecord(id: string, at: number,
kind: ProbeKind, code: number, summary: string): ProbeRecord {
return {
id, at, kind,
httpCode: kind === 'ok' || kind === 'http_error' ? code : 0,
transportCode: kind === 'transport_error' ? code : 0,
summary: summary.slice(0, 48)
};
}
两个错误码不能混用。例如收到 HTTP 503 时 kind 应是 http_error、httpCode 为 503、transportCode 为零;真正请求被拒绝、仅得到 2300007 时,反过来只写传输码。逻辑取消则两个码都是零,因为“取消”不是网络故障。这条约定延续第三篇的分类结果,也避免将 0 偷偷解释成真实 HTTP 响应。保留一个短摘要是为列表阅读服务,它不是未经处理的服务器报错字符串。
二、限制条数和失败策略,比 JSON.stringify 更值得设计
最近记录属于体量不大的本地偏好信息。这里选择 Preferences,单文件名 linkmirror_history、单键 recent_probes_v1、最多保留 10 条,使用 getPreferences、get、put 和 flush;在使用 XML 类型存储时,flush 负责将缓存修改持久化。数据量增长、需要复杂查询或者跨进程写入时,应该改为合适的数据库或日志系统,而不是无限增大单个键值。Preferences 官方也明确提醒多进程使用不具备线程安全保障,所以本文明确为单进程、单实例、串行追加的实现。
// entry/src/main/ets/store/ProbeHistory.ets
import { common } from '@kit.AbilityKit';
import { preferences } from '@kit.ArkData';
import { ProbeRecord } from '../model/ProbeRecord';
export class ProbeHistory {
private data?: preferences.Preferences;
private rows: ProbeRecord[] = [];
private blocked: boolean = false;
private chain: Promise<void> = Promise.resolve();
async init(ctx: common.UIAbilityContext): Promise<ProbeRecord[]> {
this.data = await preferences.getPreferences(ctx, 'linkmirror_history');
const raw = await this.data.get('recent_probes_v1', '[]') as string;
try {
const parsed = JSON.parse(raw) as ProbeRecord[];
if (!Array.isArray(parsed) || parsed.length > 10) {
throw new Error('历史记录格式不符合约束');
}
for (const item of parsed) {
if (!item || typeof item.id !== 'string' ||
typeof item.at !== 'number' || typeof item.kind !== 'string' ||
typeof item.httpCode !== 'number' ||
typeof item.transportCode !== 'number' ||
typeof item.summary !== 'string') {
throw new Error('存在不完整的历史记录');
}
}
this.rows = parsed;
} catch (e) {
this.blocked = true; // 保护旧文件,禁止静默覆盖
throw new Error('检测记录需要检查,已暂停写入');
}
return this.rows.slice();
}
append(record: ProbeRecord): Promise<void> {
const work = this.chain.then(async (): Promise<void> => {
if (this.blocked || !this.data) { throw new Error('记录仓库不可写'); }
const next = [record, ...this.rows].slice(0, 10);
try {
await this.data.put('recent_probes_v1', JSON.stringify(next));
await this.data.flush();
this.rows = next;
} catch (e) {
this.blocked = true;
throw e;
}
});
this.chain = work.then(() => {}, () => {});
return work;
}
snapshot(): ProbeRecord[] { return this.rows.slice(); }
}
这段仓库逻辑有三个值得交代的限制。其一,JSON.parse 的失败不能转化为“空列表并覆盖旧文件”,否则一份可以供人工修复的损坏记录可能在下次点击时被毁掉;示例选择锁住写入,并在页面显示“记录需要检查”。其二,写入队列依次调用 put 与 flush,避免两次检测同时从旧数组生成不同快照,最后写入者覆盖较早记录。其三,flush 失败后 Preferences 内存缓存可能已经被 put 修改,示例不把这次记录宣称为已保存,也禁止继续写入;需要重启仓库并诊断磁盘状态,而不能靠连续重试假装拥有数据库事务语义。此处没有跨进程一致性,也不保证异常断电时的全事务能力。

三、在正确的时机落记录,不让迟到回调制造假历史
从请求流程看,新增历史写入并不等于在 finally 里随便追加一条。finally 无论成功、失败、取消还是过期结果都会执行;它只适合资源清理,不适合判断业务结局。原 startProbe() 已有 id !== this.currentId 检查,新版必须在通过这个检查之后才写成功或失败的记录。用户点击“取消检测”时则由 cancelProbe() 主动生成一条 cancelled 记录,已经被取消的旧请求即使随后返回 HTTP 200,也不能再写成功历史。
// NetworkProbePage.ets 中的关键衔接逻辑
import { ProbeHistory } from '../store/ProbeHistory';
import { createRecord, ProbeKind, ProbeRecord } from '../model/ProbeRecord';
private history: ProbeHistory = new ProbeHistory();
@State private recent: ProbeRecord[] = [];
@State private historyTip: string = '';
private async recordAccepted(id: number,
kind: ProbeKind, code: number, summary: string): Promise<void> {
const entry = createRecord(`${Date.now()}-${id}`,
Date.now(), kind, code, summary);
try {
await this.history.append(entry);
this.recent = this.history.snapshot();
} catch (e) {
this.historyTip = '本次结果已显示,但历史写入失败';
}
}
// 在原请求成功分支完成 currentId 判断后:
// this.recordAccepted(id, response.responseCode >= 200 &&
// response.responseCode < 300 ? 'ok' : 'http_error',
// response.responseCode, advice.title);
// 在原 catch 分支完成 currentId 判断后:
// this.recordAccepted(id, 'transport_error', err.code, advice.title);
// 取消操作递增 currentId 并切换 cancelled 后:
// this.recordAccepted(this.currentId, 'cancelled', 0, '用户取消检测');
这里特意把 recordAccepted() 设计成独立异步提交:请求状态可以先及时展示,历史落盘的成功与否由另一条提示反馈,二者不能合并成“全部成功”。实际整合时应当在页面初始化中先调用 history.init(getContext(this) as common.UIAbilityContext),成功后赋值 recent;初始化失败时设置 historyTip 并禁用继续保存。上面的三处调用展示的是与上一版方法的插入位置,不是把注释当成可以直接运行的调用;需要将调用语句放入对应分支并配合第三篇完整的 startProbe() 才构成页面实现。
还有一个容易忽略的竞态:第一个已被接受的请求刚开始写历史,用户马上触发第二次检测。此时不能再以“当前是不是最新请求”来撤销第一个已经成立的记录;请求代次负责判定请求完成时是否有权提交结果,写入队列负责确保已接受的事件按顺序落盘。两种顺序分别管理,才不会一边解决迟到覆盖,一边制造历史缺失。
四、最近检测界面该怎样讲事实
列表使用稳定 id 作为 ForEach 键;每行只展示本地时间、结果分类、状态码和简短文字。列表顶端说明“仅保留最近 10 次”;如果记录为空,应显示“暂无检测记录”,不能自动拿三条示例记录填成真实历史。配图中的三条标有“示例”的条目是用于解释页面层级的示例数据,落地页面初次启动时应从真实仓库读取,默认空列表。网络目标已经固定,也没有必要在每条记录中重复打印地址。
列表可为 ok 使用绿色符号,为 http_error 使用琥珀色,为 transport_error 使用红色,为 cancelled 使用灰色。颜色只是辅助手段,文字标签同样不可省略。对于 HTTP 503,显示“服务端异常 · HTTP 503”;对于传输错误 2300028,显示“请求超时 · 传输异常”;对于用户取消,显示“用户取消”,而不再生成误导性的“网络失败”。点击某条记录如果只打开本地解释,也要继续保持敏感信息不回流的原则。列表部分可以沿用声明式 UI,不需要把底层请求对象带入组件树:
@Builder
private recentRecords() {
List({ space: 12 }) {
ForEach(this.recent, (item: ProbeRecord) => {
ListItem() {
Column({ space: 6 }) {
Text(item.summary).fontSize(17).fontWeight(FontWeight.Medium)
Text(new Date(item.at).toLocaleString())
.fontSize(12).fontColor('#64748B')
}.padding(14).width('100%')
}
}, (item: ProbeRecord) => item.id)
}.width('100%')
}
id 是列表身份,不是业务错误码;更新某条记录时不得用数组下标替代它。实例首次启动如果 recent 为空,页面另用 if (this.recent.length === 0) 显示“暂无检测记录”,不要造出成功记录填充空白。

如何验证?先用纯函数构造四条记录,检查 createRecord 是否只设置对应类别的错误码;再在可控测试入口构造“成功、取消、旧请求迟到”交错,确认只有被接受的结果入队。断开网络后尝试真正的请求错误,在 DevEco Studio 里检查 UI 与错误类型是否一致。然后重启应用,确认最多恢复十条。最后人为准备一份非法结构和一次被拒绝的持久化操作,检查页面是否进入保护状态。这里给出的 200/503/2300028 都是测试输入示例,不是公网服务已实际返回的测量结果。
由于记录包含本地事件时间,设备时钟被用户调整时,排序不能机械理解为真实的网络先后顺序;当前实现采用写入时的队列顺序,时间仅供阅读。若未来要做跨设备同步、长时间留存、故障追踪与可审计导出,就应补充时钟同步、脱敏、数据寿命、用户删除入口和更可靠的存储协议。这已经超出了一个轻量网络诊断页的范围。
这一篇到这里收住“链路镜”四篇工程主线:第一篇建立 HTTPS 检测与状态机,第二篇隔离取消与迟到结果,第三篇辨认 HTTP 与传输错误,第四篇把可接受的结果变成最小化历史。整个项目不需要把一次探测包装成全网健康结论;能够说明请求到了哪一层、哪些结果被接受,以及为什么没有留下某些数据,已经是工程上更可信的完成形态。
参考华为开发者官方文档:NetworkKit HTTP 接口、Preferences 键值存储。
更多推荐

所有评论(0)