HarmonyOS ArkTS:大文件分块解析与界面节流(连载07)【共创稿事节】
LedgerDock的CSV导入已经跨过了几道容易遗漏的关口:引号字段可以跨物理行,错误行能够定位到起始位置,金额统一用整数分保存,重复批次则按业务指纹识别。前六篇为了看清每个分支,始终使用四笔账单:TX001物料12500分、TX002配件8600分、TX003差旅的原始金额12.5x、TX004办公4300分。有效合计25400分,目标27000分、差额1600分。这些数值会继续作为分块边界的回归样本,而不是因为文件变大就改变结果。
现在要处理另一个层面的问题:导入一个月度账单时,不能把整个文件先读成一个巨大的字符串,再在UI回调里一次性切分。前者让内存峰值跟着文件体积增长,后者会在解析期间推迟触摸和进度条的响应。更糟的是,即使改成4KiB读一次,如果每接收一块就向页面发布全部已解析记录,屏幕仍可能频繁重建几千个列表项。分块读取解决数据通路的瞬时占用,界面节流解决渲染发布频率;两者要分开设计。

文件块不是CSV记录块
分块读取最容易写错的地方是默认每4096字节都恰好落在一行末尾。实际上文件边界与CSV语义毫无关系。一个块可能停在中文字符的UTF-8中间,也可能停在两个连续双引号之间,还可能把TX002说明字段的跨行文本切成三段。把字节直接按块转换成字符串,再用换行拆分,会破坏第三篇建立的引号状态机;在控制台看似读到了正确字节数,业务笔数却可能多出一条。
因此保留三个连续但独立的层次:文件读取层产出带偏移量的原始字节;文本解码层保持未完成的多字节字符;CSV扫描层保持quoted、待定双引号和当前逻辑记录缓冲。只有扫描器认定一条记录完整,才交给第二篇本地HAR公开端口里的字段校验器。这里尤其不能在每次文件读取后调用扫描器的finish(),它只能用于文件流真正结束时收口,否则合法的跨行引号会被当成引号未闭合。
应用私有目录已经拥有可访问路径时,可以借助Core File Kit的文件接口完成分块读取。下面给出读取层与文本解码层的连接方式。接口使用一次读取、一段偏移,finally始终关闭文件句柄;对于通过文件选择器得到的URI,应先按获得的授权方式处理,不能把URI生硬拼接成应用私有路径。读取层既不决定CSV字段,也不触碰任何页面组件。
import { fileIo } from '@kit.CoreFileKit';
import { util } from '@kit.ArkTS';
export async function readUtf8Chunks(
path: string,
onText: (text: string, bytes: number) => void,
shouldStop: () => boolean
): Promise<number> {
const input = fileIo.openSync(path, fileIo.OpenMode.READ_ONLY);
const decoder = new util.TextDecoder('utf-8');
const chunkSize = 4096;
let offset = 0;
try {
while (!shouldStop()) {
const buffer = new ArrayBuffer(chunkSize);
const readSize = await fileIo.read(input.fd, buffer,
{ offset: offset, length: chunkSize });
if (readSize <= 0) { break; }
offset += readSize;
const segment = new Uint8Array(buffer, 0, readSize);
onText(decoder.decodeToString(segment, { stream: true }), offset);
}
if (!shouldStop()) {
onText(decoder.decodeToString(new Uint8Array(0),
{ stream: false }), offset);
}
return offset;
} finally {
fileIo.closeSync(input);
}
}
上面的offset代表文件字节偏移,不是字符数或业务行号;文件总长度可以从文件元数据获取,专门用于计算读取进度。进度值不等同于有效记录占比,因为长引号字段可能在多个块里都尚未收口。decodeToString的流式选项用于跨块保留尚未完成的UTF-8字符,最后一次再冲刷解码状态。若所用SDK的解码接口采用不同命名,应按对应API参考调整,而不能把字节转换假设成字符转换。文件读失败、读取被取消和遇到结束标记是三种不同结果,都要向上层明确传递。
预览只取少量,统计在业务层累计
拆块后仍可能出现第二类性能问题。最开始为了方便调试,解析器每产出一条业务记录便把@State rows整个数组重新赋值,页面同时刷新进度、错误数和列表。这样在记录多时,卡片列表会不断重新分配数据,哪怕屏幕上只看得到少量记录。修复的方向是把全量业务记录放在数据层,页面只保存前若干条用于预览和几项累计计数;流结束后再选择需要的分页或查询范围。
这里将预览限定为前四条样本,并把刷新窗口设为100毫秒;窗口尚未到达时,统计继续在普通对象中累加,不强制发布UI。流结束、错误终止、用户主动取消这三种边缘时机要各发送一次最终状态,否则进度条可能永远停在99%。注意100毫秒只是刷新频率的工程参数,不是文件读取速度,也不意味着页面一定每秒刷新十次。慢文件读取只要产生新的进度即可照常显示;快文件读取不必让每条记录都触发页面重建。
以下业务类接收上一节扫描器产出的完整逻辑记录。字段校验仍作为已建立的HAR能力从外部传入,不在这一节重写金额格式规则。这样TX003仍作为格式错误计数,另外三条继续参与对账,算法升级只影响如何投递结果,不改变结果的业务解释。
export interface ImportSnapshot {
bytesRead: number;
totalBytes: number;
logicalCount: number;
acceptedCount: number;
issueCount: number;
complete: boolean;
}
export class UiPublishGate {
private lastPublishedAt: number = -1000;
private logicalCount: number = 0;
private acceptedCount: number = 0;
private issueCount: number = 0;
private done: boolean = false;
append(valid: boolean): void {
if (this.done) { return; }
this.logicalCount++;
if (valid) { this.acceptedCount++; }
else { this.issueCount++; }
}
snapshot(bytes: number, total: number, now: number,
complete: boolean): ImportSnapshot | null {
if (!complete && now - this.lastPublishedAt < 100) { return null; }
this.lastPublishedAt = now;
this.done = complete;
return {
bytesRead: bytes,
totalBytes: total,
logicalCount: this.logicalCount,
acceptedCount: this.acceptedCount,
issueCount: this.issueCount,
complete: complete
};
}
}
CSV扫描器的push(text)可能返回零条、单条或多条完整记录。接入层应遍历每一条,先做字段合法性判定,再调用append;当某块只携带半个引号字段时,逻辑记录计数保持不动,字节偏移仍然前进。到结束时应调用扫描器的finish()处理最后一条逻辑记录,然后传complete=true取得最终快照。记录总数很大的场景应避免在这个类里保存所有原始字符串,错误定位可以改成只记录行号与错误类型,具体原文按需定位读取。
页面更新要有自己的生效代次
用户中途离开导入页、换一个文件重新开始,常见错误是旧任务的进度继续覆盖新任务。即使上一段控制了每百毫秒发布一次,只要旧读取任务尚未停止,仍可能把正在处理的新文件进度从30%改回5%。做法和第六篇的批次幂等有所联系:每次导入操作拥有独立代次,页面只接受当前代次的进度。取消或退出页面时增加代次,同时请求读取层停止;这里的代次只负责UI有效性,不能代替持久化批次指纹,也不能据此推断账单写入成功。
进度条还要区分“读取完成”和“已入库”。CSV读取到100%并不等于金额校验、同号冲突核查和事务提交都成功。界面分开显示字节读取、业务解析和待提交状态,既符合第二篇HAR协议约定,也避免用户看到满格进度就以为账本已经更新。错误行TX003仍需要留在可访问的错误列表,不能因为为预览节省内存就丢掉可回溯性。

@Entry
@Component
struct LargeImportPage {
@State readPercent: number = 0;
@State records: number = 0;
@State issues: number = 0;
@State hint: string = '等待选择账单';
private generation: number = 0;
private disposed: boolean = false;
aboutToAppear(): void { this.disposed = false; }
aboutToDisappear(): void {
this.disposed = true;
this.generation++;
}
private publish(view: ImportSnapshot, runId: number): void {
if (this.disposed || runId !== this.generation) { return; }
const ratio = view.totalBytes <= 0 ? 0 :
Math.floor(view.bytesRead * 100 / view.totalBytes);
this.readPercent = Math.min(100, ratio);
this.records = view.logicalCount;
this.issues = view.issueCount;
this.hint = view.complete ? '解析完成,等待对账确认' : '正在分块读取';
}
build() {
Column({ space: 14 }) {
Text('费用账单导入').fontSize(22).fontWeight(FontWeight.Bold)
Text(this.hint)
Progress({ value: this.readPercent, total: 100,
type: ProgressType.Linear })
Text(`读取 ${this.readPercent}%`)
Text(`已解析 ${this.records} 笔 · 错误 ${this.issues} 笔`)
Button('取消当前读取').onClick(() => {
this.generation++;
this.hint = '读取已取消';
})
}.padding(18).width('100%')
}
}
这里的publish由文件服务层的进度观察回调触发,启动新文件前应获取新的代次并把停止函数交给读取层,不能只把页面上的百分比清零。aboutToDisappear的代次递增可以拦住已经排队等待投递的回调,读写句柄仍由第一段代码的finally回收。若短时间内多次切换文件,读取层还应限制同时存在的打开句柄数量,避免资源消耗随着页面操作次数积累。
对边界进行针对性检查
四条小样本依然适合验证大文件解析器的正确性。把相同CSV内容切成1字节、3字节、16字节和4096字节等不同组合,最终都应该得到四条业务记录、三条有效数据、一条TX003错误;TX002的引号跨行内容应保持为一条记录。再准备UTF-8汉字位于缓冲区边缘、双引号两个字节分属相邻块、CRLF拆在两个块、文件缺失末尾换行、最后一条引号未闭合等输入,分别检查字节解码和扫描器状态。还要让用户在读取中途取消、切换文件、离开页面,确认旧代次不能反写新页面。
内存观察重点不是“处理了多少条”这个数字,而是当前保留了多少未收口字节、多少完整记录对象和多少预览卡片。对于CSV格式中极长且始终不闭合的引号字段,扫描缓冲区仍可能无限增长,所以必须为单条记录设置字节上限,超限后形成带位置的错误并转入可恢复的扫描策略;如果业务要求原文留存,应交给应用私有临时文件,而不是无限放大页面状态数组。需要严格区分系统异步读文件与解析本身的CPU消耗,二者不一定自动分散到不同线程。

参考华为开发者的ArkTS与应用开发文档中心、Core File Kit文件读写示例以及ArkUI状态更新和@Watch说明。这次调整之后,解析器的职责仍是保证记录含义正确,读取层负责字节边界与资源释放,UI只按合适频率公布可见进度。三层各守住自己的状态,文件量增加时才不会把账单准确性和页面响应拖到一起。
更多推荐


所有评论(0)