Picker + HarmonyOS 本地 CSV 导入:临时授权、逐行校验与确认落盘【共创稿事节】
一份只有几千字节的 CSV 文件,也可能让移动应用陷入一连串尴尬:选择器返回了地址却打不开、中文名字被解码成乱码、表头顺序被改动后数据错列,甚至有两条错误记录混进了用户的正式账本。与把一段字符串简单 split(',') 然后展示相比,“导入”是一条完整的信任链。以 PlantLedger 植物养护记录为例,我们希望用户从系统文件管理器选择本地 CSV,先看到逐行验证结果,再主动确认哪些记录会进入应用沙箱。文件读取和持久化是两个不同动作,中间必须留出审查的界面。

一、先限定契约,才能说清导入成功的含义
PlantLedger 的最小格式只有三列,严格按 date,plant,minutes 排列。date 使用 YYYY-MM-DD,plant 是植物名称,minutes 是当天照料用时,要求为 0 到 240 的非负整数。我们把本篇定义成“简单 CSV 子集”:UTF-8 文本、英文逗号分隔、固定三列、没有引号转义、字段内部没有换行与逗号。这个边界非常重要;RFC 4180 的完整 CSV 可以包含 "a,b" 这样的引号字段,直接 split(',') 会把它错拆。本例明确拒绝复杂字段,不伪称实现了通用 CSV 解析器。真正对接财务或办公系统时应改用经过审查的完整解析库。
用于复现的 plants.csv 内容如下。表头之后有六条记录,其中“分钟数字母”和“植物名称为空”是故意保留的坏行。我们的预期是四条通过、两条拒绝,并准确指出文件物理行号:第 4 行分钟无效,第 5 行缺少名称。第 7 行的零分钟是合法值,不能像处理布尔假值一样把它过滤掉。
date,plant,minutes
2026-11-02,琴叶榕,8
2026-11-03,绿萝,5
2026-11-04,龟背竹,abc
2026-11-05,,7
2026-11-06,薄荷,4
2026-11-07,多肉,0
选择器拿到的不是应用目录里永远有效的绝对路径,而是用户授权的文件 URI。华为的 DocumentViewPicker 会拉起系统界面,由用户挑选文件;相关文档明确说明 select() 返回的 URI 授权是临时性质,应用进入后台后可能失效。这意味着导入流程不应把 URI 存到 Preferences 后隔天再读取。更可靠的选择是尽快读取内容,生成应用自有的数据快照,经用户确认再写入应用沙箱。用户取消选择和文件突然失效是可预见的正常分支,而不是需要崩溃退出的异常。
本文以支持相应接口的 Stage 模型 ArkTS 工程为前提,示例使用 @kit.CoreFileKit 中的 picker、fileIo,@kit.ArkTS 中的 util.TextDecoder。系统选择器能力无需额外声明读写整个外部存储的高权限,不能为了图省事而请求与功能无关的广泛存储权限。不同设备与 SDK 的选择界面、后缀过滤项实现可能不同,应以实际目标版本的文档和设备行为验证。文件读取上限设为 128 KiB,避免一个看起来很小的导入按钮无意中加载了超大文件。
二、选择文件与读取字节:先把权限生命周期处理好
在页面点击事件中构造 DocumentViewPicker,使用 UIAbility 的 Context 调起系统文件选择器。返回 URI 后以 READ_ONLY 打开,循环读取字节,最后无论正常结束还是出现解码异常,都关闭文件句柄。分块读取时最容易犯的错误是对每个数据块分别调用 UTF-8 解码:一个中文字的三个字节可能被分到相邻两块,直接独立解码会生成替换字符。本例先收集原始字节再统一解码,内存占用受 128 KiB 上限约束,所以更适合简单导入场景。
// entry/src/main/ets/utils/CsvFileReader.ets
import { picker, fileIo } from '@kit.CoreFileKit';
import { util } from '@kit.ArkTS';
import { common } from '@kit.AbilityKit';
const MAX_BYTES: number = 128 * 1024;
export async function chooseCsv(context: common.UIAbilityContext): Promise<string> {
const options = new picker.DocumentSelectOptions();
options.maxSelectNumber = 1;
options.fileSuffixFilters = ['CSV|.csv'];
const uris: string[] = await new picker.DocumentViewPicker(context).select(options);
if (uris.length === 0) { throw new Error('未选择文件'); }
return readUtf8(uris[0]);
}
function readUtf8(uri: string): string {
const file = fileIo.openSync(uri, fileIo.OpenMode.READ_ONLY);
const parts: Uint8Array[] = [];
let total: number = 0;
try {
while (true) {
const raw = new ArrayBuffer(8192);
const size: number = fileIo.readSync(file.fd, raw);
if (size === 0) { break; }
total += size;
if (total > MAX_BYTES) { throw new Error('文件超过128 KiB限制'); }
parts.push(new Uint8Array(raw, 0, size));
}
} finally {
fileIo.closeSync(file);
}
const merged = new Uint8Array(total);
let offset: number = 0;
parts.forEach((part: Uint8Array) => {
merged.set(part, offset);
offset += part.length;
});
return util.TextDecoder.create('utf-8').decodeToString(merged);
}
try/finally 保证打开的文件得到释放,但发生异常后可能仍需要清除 UI 的加载状态;这是调用方的责任。为了避免用户连续点击产生两次选择器,可以在页面用 @State importing: boolean 做禁用状态。注意这里选中文件之后的读取还使用同步 API,这是华为示例允许的基础能力;本篇通过小文件上限约束使用范围。正式处理大文件时应迁移到异步读取或 worker 中运行,不能把长时间同步磁盘操作放在 UI 响应路径上。
三、逐行检验比“一次性成功”更可信
拿到文本之后,先统一换行符并移除文本开头可选的 UTF-8 BOM,再验证表头。这里不提供模糊的“猜列名”能力:表头必须精确匹配三列,避免用户把 hours 的值当作 minutes。随后逐行拆分固定三个字段;如果字段数不对、出现引号或分隔符异常,就记录行号与原因,不把那条记录写入最终列表。对日期也不能只做正则,因为 2026-02-30 的外观符合 YYYY-MM-DD,日历上却没有这一天。
// entry/src/main/ets/model/PlantImport.ets
export interface PlantEntry {
date: string;
plant: string;
minutes: number;
}
export interface ImportIssue {
line: number;
reason: string;
}
export interface ImportPreview {
rows: PlantEntry[];
issues: ImportIssue[];
}
function validDate(date: string): boolean {
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) { return false; }
const parts: number[] = date.split('-').map((x: string) => Number(x));
const check = new Date(Date.UTC(parts[0], parts[1] - 1, parts[2]));
return check.getUTCFullYear() === parts[0] &&
check.getUTCMonth() === parts[1] - 1 &&
check.getUTCDate() === parts[2];
}
export function parsePlantCsv(input: string): ImportPreview {
const text = input.replace(/^\uFEFF/, '').replace(/\r\n?/g, '\n');
const lines: string[] = text.split('\n');
if (lines[0].trim() !== 'date,plant,minutes') {
throw new Error('表头必须是 date,plant,minutes');
}
const rows: PlantEntry[] = [];
const issues: ImportIssue[] = [];
for (let i: number = 1; i < lines.length; i++) {
if (lines[i].trim().length === 0) { continue; }
const fields: string[] = lines[i].split(',');
const date: string = fields.length > 0 ? fields[0].trim() : '';
const plant: string = fields.length > 1 ? fields[1].trim() : '';
const rawMinutes: string = fields.length > 2 ? fields[2].trim() : '';
if (fields.length !== 3 || lines[i].includes('"')) {
issues.push({ line: i + 1, reason: '列数或引号格式不符合本例要求' });
} else if (!validDate(date)) {
issues.push({ line: i + 1, reason: '日期无效' });
} else if (!plant || plant.length > 30) {
issues.push({ line: i + 1, reason: '植物名称为空或过长' });
} else if (!/^\d+$/.test(rawMinutes) || Number(rawMinutes) > 240) {
issues.push({ line: i + 1, reason: '分钟必须是0至240的整数' });
} else {
rows.push({ date: date, plant: plant, minutes: Number(rawMinutes) });
}
}
return { rows: rows, issues: issues };
}
这是一套有意收紧的解析规则,适合用户自己生成的简单模板,但仍要说明两个边界:空行会被忽略,因此行号按照原文件计数,不是结果数组的下标;同一日期可以照料多种植物,所以不能按日期简单去重。业务若需要防重复,应明确组合键,例如日期加植物名称,并规定覆盖还是新增,不能让数据层猜测。另一个细节是如果用户用 Excel 导出为本地传统编码的 CSV,UTF-8 解码不一定符合预期;可在导入界面明确标注“UTF-8 CSV”,遇到乱码让用户重新导出,而不是静默用不可靠猜测来修正中文。

四、先预览,再落盘:把用户确认当作事务边界
假设 PlantLedger 的预览页出现“可导入 4 条,需处理 2 条”。上方显示四条有效记录,下方红色提示准确列出第 4 行和第 5 行的问题。确认导入 4 条 按钮只把验证通过的数据写入应用自身的 Preferences。这里用结构化 JSON 保存小体量个人养护记录,而不是再索取外部文件的长期访问权。存储成功之前,页面保持在预览状态;失败则保留预览供重试,不能提前显示“导入完成”。
// entry/src/main/ets/pages/ImportPage.ets 的关键成员与事件
import { common } from '@kit.AbilityKit';
import { preferences } from '@kit.ArkData';
import { chooseCsv } from '../utils/CsvFileReader';
import { ImportPreview, PlantEntry, parsePlantCsv } from '../model/PlantImport';
@Entry
@Component
struct ImportPage {
@State private preview: ImportPreview = { rows: [], issues: [] };
@State private loading: boolean = false;
@State private message: string = '请选择 UTF-8 CSV 文件';
private async pick(): Promise<void> {
if (this.loading) { return; }
this.loading = true;
try {
const context = getContext(this) as common.UIAbilityContext;
const text = await chooseCsv(context);
this.preview = parsePlantCsv(text);
this.message = `可导入 ${this.preview.rows.length} 条,需处理 ${this.preview.issues.length} 条`;
} catch (error) {
this.message = '选择或解析失败,请核对格式后重试';
} finally {
this.loading = false;
}
}
private async confirm(): Promise<void> {
if (this.loading || this.preview.rows.length === 0) { return; }
this.loading = true;
try {
const context = getContext(this) as common.UIAbilityContext;
const store = await preferences.getPreferences(context, 'plantledger');
// 此版本采用整批替换策略,避免重复点击造成重复条目。
await store.put('entries', JSON.stringify(this.preview.rows));
await store.flush();
this.message = `已保存 ${this.preview.rows.length} 条有效记录`;
} catch (error) {
this.message = '保存失败,预览仍保留,可再次确认';
} finally {
this.loading = false;
}
}
build() {
Scroll() {
Column({ space: 14 }) {
Text('PlantLedger').fontSize(19).fontColor('#437F66')
Text('导入植物养护 CSV').fontSize(26).fontWeight(FontWeight.Bold)
Text(this.message).fontSize(15)
Button('选择 CSV').enabled(!this.loading)
.onClick(() => { void this.pick(); })
ForEach(this.preview.rows, (item: PlantEntry) => {
Text(`${item.date} ${item.plant} ${item.minutes} 分钟`)
.padding(12).backgroundColor('#EFF8F2')
}, (item: PlantEntry, index: number) => `${item.date}-${item.plant}-${index}`)
ForEach(this.preview.issues, (item) => {
Text(`第 ${item.line} 行:${item.reason}`)
.fontColor('#A63835').fontSize(14)
}, (item, index: number) => `${item.line}-${index}`)
Button('确认导入有效记录').enabled(!this.loading && this.preview.rows.length > 0)
.onClick(() => { void this.confirm(); })
}.padding(20).width('100%')
}.height('100%').backgroundColor('#FAFCF9')
}
}
这里的策略是“本次有效记录整体替换旧列表”,因为它最容易说明确认动作的范围,也不会在用户重复点击时产生两份相同的记录。如果真实产品要求追加合并,就必须先读取旧值,再定义复合键、冲突规则、持久化写入与更新后展示的次序,不能把两者混成一句“保存成功”。preferences 对少量配置和轻量数据方便;记录数量上升、需要复杂查询或多事务更新时应选关系数据库。示例界面与代码都使用同一个“可导入 4 条、需处理 2 条”的数据状态,不能把系统文件选择后的 UI 当成已经成功落盘的证明。

五、把错误场景当成验收主路径
一个有效的导入验证清单应该首先测试坏文件:没有表头的 CSV 应拒绝解析;带引号逗号的复杂 CSV 应明确拒绝,不把被拆碎的字段偷偷塞进植物名称;不存在的日期、负分钟、超过 240 的分钟、空名称都应该给出行号。用本文样例验证时,预览有四条有效记录,坏行应定位在第 4、5 行。确认后重新读取 Preferences 的 entries,解析 JSON,核对条目数是四,并且 多肉 的 minutes 等于零。
接着测试系统路径。点击选择器再取消,页面不应清空上一次有效预览;文件在选择后变成不可访问时,应在读文件异常中提示重新选择;文件超过 128 KiB 时应直接拒绝。连续快速点击两个按钮,loading 状态应阻止重复选取和重复确认。若 flush() 失败,状态不得显示成功,也不能把上次的预览销毁。真实项目还可以进一步保留脱敏错误码,方便分析是 URI 授权失效还是文件内容问题,但日志里不应该输出用户完整的养护记录与私有路径。
因为 UI 图是代码对应的预期效果,不能以它证明系统 Picker 已在某一台设备上完成授权。实际验收至少要在目标 API SDK 的手机或预览环境上跑通一次“选择—解析—预览—确认—重启后读取”,核对文件处理后是否有句柄泄漏,尤其要观察应用切到后台后临时 URI 的可访问性变化。DocumentViewPicker 是由用户发起的显式文件访问,安全边界由系统选择器和临时授权提供;应用自己依然负责格式校验、容量限制、业务合法性与结果落盘。

这一套工程可以迁移到课程名单、资产台账、小型实验记录等场景,但前提是先定义每份 CSV 的语义和验收口径。真正可靠的体验并不是导入按钮按下后立刻出现一条“成功”Toast,而是用户能看清哪些行通过、哪些行被拒、确认后数据落在哪里,以及失败时是否保留可恢复的工作状态。把系统授权、编码边界、字段检验和持久化确认拆开,导入功能才从一次文件读取变成一个可以维护的业务闭环。
参考资料:华为文件选择器 Picker API、选择用户文件与临时 URI 授权、基础文件读写 fs API、UTF-8 TextDecoder、Preferences 数据存储。
更多推荐



所有评论(0)