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仍需要留在可访问的错误列表,不能因为为预览节省内存就丢掉可回溯性。

DevEco Studio分块读取与节流状态实现

@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只按合适频率公布可见进度。三层各守住自己的状态,文件量增加时才不会把账单准确性和页面响应拖到一起。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐