CSV 的逗号不总是分隔符:把 kotlin-csv 接入鸿蒙的读写记录
1. 从一条多出来的字段查起
导出一份名单,第一列姓名,第二列备注。备注写着“喜欢摄影,也喜欢徒步”,到了导入界面却多出一列。换一条“他说你好”的内容,双引号又被处理得奇奇怪怪。CSV 很容易给人一种错觉:每行一条记录,逗号一拆就能读。只要允许字段里出现逗号、引号或者换行,这个办法就开始丢失数据的含义。
我把这几个问题集中放进一个鸿蒙读写工作台:左侧编辑 CSV,右侧按记录显示列数与字段,下方单独保留重新编码后的文本。三块区域可以同时对照,不用反复切页。文本路径和 UTF-8 字节路径都有独立模式,还给应用沙箱中的文件读写留了一个入口。这样观察的重点就不只是“能显示一张表”,而是同一份内容经过不同入口后,有没有保持原来的字段。
kotlin-csv 的当前源码已经把解析、编码和 I/O 分开组织。本文使用这份共享实现,新增 harmony 平台构建,通过 CPF-KMP-CMP 的鸿蒙 Native 工具链接入应用。下面按实际排查顺序讲:先确定格式语义,再补依赖,最后用往返操作检查内容。
2. 第一轮排查:先把引号读明白
第一组输入是两列名单,备注字段里同时放入逗号和双引号。带逗号的字段应被引号包住;字段内部真正的双引号按默认方言成对出现。读取时,外层引号用于描述格式,里面转义后的引号才属于字段内容。看懂这层关系后,才能判断页面显示的字符串到底是数据,还是 CSV 编码本身。
第二组输入让备注跨两行。它仍然是一条记录里的一个字段,因此源文本的视觉行数和 CSV 记录数量不一定相同。示例先由原库产生完整行,再显示每行有几列,不提前按换行拆文本。否则还没进入解析器,一条有意义的记录就已经被拆断。
第三组把分隔符换成分号。页面选择对应模式后,读者和写者共同使用同一份 CsvDialect。只改变读取配置,却继续按逗号写回,会让下一次读取采用不同规则;这个例子把读写双方的方言放到一起,减少这种不一致。分号模式也不会顺手猜测文本格式,必须由用户明确选择。
错误组故意留下未闭合引号。终止读取时库会抛出格式异常,桥把原因转成错误结果呈现。随后再选正常表格,应当能恢复解析。这里没有采用“遇到异常就返回空列表”的处理,因为空文件和格式损坏是两种情况,用户需要知道为什么数据没被导入。
3. 第二轮排查:两条路径都必须走进原库
文本模式使用 CsvReader.readAll 与 CsvWriter.writeAll,把全部行整理成内存列表。它适合当前这样的短输入,结构清楚,能够直接对比原始文本、字段列表和重新编码的文本。源码中还有惰性的 Sequence 接口,读取动作在遍历时发生,异常也可能到终止操作才出现,调用方不能只包住“创建序列”的那一行。

字节模式使用 kotlinx-io 的 Buffer。输入先编码为 UTF-8 写入 Buffer,原库的 Source 扩展读取其中的内容;写回时使用 Sink 扩展,最后再把 Buffer 读成字符串。这样字节入口经过的仍然是共享 CSV 实现,能覆盖中文、补充平面字符和带字节顺序标记的文本,而页面不用另写字节到字符的转换循环。
下面是 harmony/src/Bridge.kt 的完整内容。文件包含两套入口:evaluate 在文本和 UTF-8 Buffer 间切换,fileRoundTrip 把同一份行数据写进应用沙箱后重新读取。它们共同使用 reader、writer 和返回编码,文件入口还会检查路径。下面从 import 到检查统计全部列出,读者不用再补一份未给出的 CSV 辅助函数。
package harmony
import com.jsoizo.kotlincsv.reader.*
import com.jsoizo.kotlincsv.writer.*
import com.jsoizo.kotlincsv.CsvDialect
import com.jsoizo.kotlincsv.exceptions.*
import kotlinx.io.Buffer
import kotlinx.io.readString
private fun reader(mode:Int)=CsvReader(CsvReaderConfig(dialect=CsvDialect(delimiter=if(mode==2)';' else ',')))
private fun writer(mode:Int)=CsvWriter(CsvWriterConfig(dialect=CsvDialect(delimiter=if(mode==2)';' else ',')))
private fun parsed(input:String,mode:Int):List<List<String>> = if(mode==1) {
val bytes=Buffer(); bytes.write(input.encodeToByteArray()); reader(mode).readAll(bytes)
} else reader(mode).readAll(input)
private fun encoded(rows:List<List<String>>,mode:Int):String=if(mode==1) {
val bytes=Buffer();writer(mode).write(rows,bytes);bytes.readString()
} else writer(mode).writeAll(rows)
fun evaluate(input:String,mode:Int):String=try {
require(mode in 0..2) { "未知模式" };require(input.length<=8192) { "演示输入最多 8192 字符" }
val rows=parsed(input,mode); val text=encoded(rows,mode); val round=parsed(text,mode)==rows
val output=rows.mapIndexed { i,cells->row("第 ${i+1} 行 · ${cells.size} 列",cells.joinToString(" | ") { if(it.isEmpty())"〈空〉" else it }) }.toMutableList()
output+=row("重新编码",text);output+=row("写入后重读",if(round)"内容一致" else "内容不同")
result("${rows.size} 行 · "+when(mode){1->"UTF-8 Buffer I/O";2->"分号分隔";else->"文本读写"},output,round)
} catch(e:Throwable) { result("CSV 输入有问题",listOf(row("原因",e.message?:"解析失败")),false) }
fun fileRoundTrip(input:String,path:String):String=try {
require(path.isNotBlank() && path.startsWith("/data/")) { "需要应用沙箱的绝对路径" }
val rows=reader(0).readAll(input);writer(0).writeToFile(rows,path)
val loaded=reader(0).readAllFromFile(path)
result("沙箱文件写入后读取",listOf(row("行数",loaded.size.toString()),row("内容核对",if(rows==loaded)"内容一致" else "内容不同")),rows==loaded)
} catch(e:Throwable) { result("文件读写失败",listOf(row("原因",e.message?:"I/O 失败")),false) }
private fun round(rows:List<List<String>>)=parsed(encoded(rows,1),1)==rows
fun runChecks():String=checks(listOf(
"普通两列" to { parsed("a,b\nc,d",0)==listOf(listOf("a","b"),listOf("c","d")) },
"引号中的逗号" to { parsed("\"a,b\",c",0)[0][0]=="a,b" },
"双引号转义" to { parsed("\"a\"\"b\",c",0)[0][0]=="a\"b" },
"引号内换行" to { parsed("\"a\nb\",c",0)[0][0]=="a\nb" },
"尾部空字段" to { parsed("a,b,",0)[0]==listOf("a","b","") },
"中文及补充平面字符" to { round(listOf(listOf("鸿蒙","😀"))) },
"空字段往返" to { round(listOf(listOf("","b",""))) },
"逗号引号换行往返" to { round(listOf(listOf("a,b","a\"b","a\nb"))) },
"UTF-8 BOM 去除" to { parsed("\uFEFF姓名,年龄\n小明,18",1)[0][0]=="姓名" },
"分号方言" to { parsed("a;b\nc;d",2)[1]==listOf("c","d") },
"CRLF 行尾" to { parsed("a,b\r\nc,d\r\n",0).size==2 },
"未闭合引号拒绝" to { try{parsed("\"abc",0);false}catch(_:CsvParseFormatException){true} },
"字段数量错误拒绝" to { try{parsed("a,b\nc",0);false}catch(_:CsvFieldNumDifferentException){true} },
"延迟序列多次迭代" to { val sequence=reader(0).read("a,b\nc,d".asSequence());sequence.toList()==sequence.toList() },
"错误后仍能解析" to { evaluate("\"abc",0).contains("\"ok\":false") && evaluate("a,b",0).contains("\"ok\":true") }
))
private fun quote(s: String): String = "\"" + buildString {
s.forEach { c -> when(c) { '"' -> append("\\\""); '\\' -> append("\\\\"); '\n' -> append("\\n"); '\r' -> append("\\r"); '\t' -> append("\\t"); else -> if(c.code < 32) append("\\u"+c.code.toString(16).padStart(4,'0')) else append(c) } }
} + "\""
private fun row(label: String, value: String, metric: Double = 0.0): String = "{\"label\":"+quote(label)+",\"value\":"+quote(value)+",\"metric\":"+(if(metric.isFinite()) metric else 0.0)+"}"
private fun result(summary: String, rows: List<String>, ok: Boolean = true): String = "{\"ok\":$ok,\"summary\":"+quote(summary)+",\"rows\":"+rows.joinToString(",","[","]")+"}"
private fun checks(cases: List<Pair<String, () -> Boolean>>): String {
var passed = 0
val details = cases.joinToString("\n") { (name,test) ->
try { if(test()) { passed++; "PASS $name" } else "FAIL $name" }
catch(e: Throwable) { "FAIL $name: ${e.message}" }
}
return "{\"passed\":$passed,\"total\":${cases.size},\"detail\":"+quote(details)+"}"
}
“往返一致”比较的是重新读取后的字段列表。写者会采用自己的规范编码,比如输出行尾、为必要字段加引号;因此不能要求重新编码文本和输入文本逐字一样。两段 CSV 文本的引号写法可能不同,表示的数据却一致。这也是示例把字段核对和编码预览同时显示的原因,读者可以看到哪一层发生了变化。
空字段在页面中用一个明确的空标记显示,避免两条竖线之间的一点空白被误认为漏列。这里仍然读取字符串字段,没有把每个空字符串自动解释成 null。上游提供可空读取与相关配置,业务如果要区分“没有值”和“空文本”,还需要选择对应规则,不能从表格看起来空着就擅自转换。
列数也属于格式约定。第一条记录建立预期列数,后面的记录如果少列或多列,默认策略会报告异常。这对名单导入比较实用,因为错位数据不应该悄悄进入结果列表。若业务允许补空字段或忽略坏行,上游有相应策略可配置,但应同时给出被处理的记录信息。当前示例保留默认报错,方便读者看到有问题的输入确实被识别。

4. 第三轮排查:鸿蒙依赖要补全
环境搭建引用 CPF-KMP-CMP 官方快速开始。当前上游构建使用的 Kotlin 版本与鸿蒙发行工具链不同,所以平台配置集中放到新增 harmony 目录,原仓版本目录和其他平台配置保留。实际命令行编译采用鸿蒙 Kotlin/Native 2.2.21-1.1.0,生成 ARM64 和 X64 两套原生库。
这份 CSV 源码的 commonMain 已经依赖 kotlinx-io。ReaderIo 与 WriterIo 同时包含 Buffer 通用入口和文件入口,完整编译它们需要匹配鸿蒙目标的 core 与 bytestring 依赖。只删掉 I/O 文件编译剩余解析器,能得到一个较小的文本演示,却会丢掉原库已经公开的一部分能力。本次保留全部共享文件,把依赖按目标架构接进构建。
依赖使用鸿蒙 Maven 发行的零点九兼容版本,ARM64 与 X64 各自有对应 KLIB。下载来源、版本和摘要随 harmony/dependencies 保存,POM 中的 bytestring 依赖也一并核对。普通 JVM 包、Linux Native 库和鸿蒙库的目标不同,不能只看包名一样就替换使用。
Bridge 负责选择方言、执行读写和组装返回值,N-API 负责参数检查与字符串生命周期,ArkTS 负责输入和结果卡片。原生返回字符串复制完成后释放,读写异常保留在可显示的错误结构中。动态库和生成的 C 头文件成对使用,修改 Kotlin 入口后两种产物一起更新,避免接口已经改变而调用方还按旧结构编译。
把接口定义和应用配置放到一起看,库到页面的调用次序才完整。下表中 entry 路径均位于 example/harmony 中;生成的 C 头文件由原生构建脚本输出,不需要手工猜测导出结构。
| 文件 | 在调用链中的职责 |
|
| 原库调用、返回编码与设备检查 |
|
| 共享源集、双 ABI 依赖和编译选项 |
|
| 编译 KLIB、链接动态库并生成匹配头文件 |
|
| 参数检查、原生调用与返回值生命周期 |
|
| 按 ABI 选择动态库和头文件 |
|
| ArkTS 能看到的接口类型 |
|
| 完整输入、运行反馈与独立界面 |
4.1 三个 CSV 读写接口的原生出口
N-API 先核对两个参数,再按 UTF-8 字节数分配缓冲区。Kotlin 返回的字符串先复制到 ArkTS 值中,随后调用 DisposeString 释放。下面给出整个文件,包含模块注册;只有 Evaluate 函数而没有 Init 和 Register,页面就无法从 libentry.so 取得接口。 CSV 的文件入口单独导出为 fileRoundTrip,它和普通 evaluate 采用不同参数,不能在声明文件中漏掉。
完整文件:example/harmony/entry/src/main/cpp/napi_init.cpp。
#include "napi/native_api.h"
#include "kotlincsv_api.h"
#include <string>
static napi_value Copy(napi_env env,const char* text) {
napi_value value;
napi_create_string_utf8(env,text?text:"{}",NAPI_AUTO_LENGTH,&value);
if(text) kotlincsv_symbols()->DisposeString(text);
return value;
}
static napi_value Evaluate(napi_env env,napi_callback_info info) {
size_t count=2;napi_value args[2];napi_get_cb_info(env,info,&count,args,nullptr,nullptr);
size_t length=0;int32_t mode=0;
if(count!=2 || napi_get_value_string_utf8(env,args[0],nullptr,0,&length)!=napi_ok || napi_get_value_int32(env,args[1],&mode)!=napi_ok) {
napi_throw_type_error(env,nullptr,"evaluate requires text and a mode");return nullptr;
}
if(length>16384){napi_throw_range_error(env,nullptr,"Input exceeds demo limit");return nullptr;}
std::string text(length+1,'\0');napi_get_value_string_utf8(env,args[0],text.data(),text.size(),&length);text.resize(length);
return Copy(env,kotlincsv_symbols()->kotlin.root.harmony.evaluate(text.c_str(),mode));
}
static napi_value Check(napi_env env,napi_callback_info info){return Copy(env,kotlincsv_symbols()->kotlin.root.harmony.runChecks());}
static napi_value FileRoundTrip(napi_env env,napi_callback_info info) {
size_t count=2;napi_value args[2];napi_get_cb_info(env,info,&count,args,nullptr,nullptr);
std::string values[2];
if(count!=2){napi_throw_type_error(env,nullptr,"fileRoundTrip requires text and a path");return nullptr;}
for(size_t i=0;i<2;i++){
size_t length=0;if(napi_get_value_string_utf8(env,args[i],nullptr,0,&length)!=napi_ok){napi_throw_type_error(env,nullptr,"String argument required");return nullptr;}
if(length>(i==0?16384:4096)){napi_throw_range_error(env,nullptr,"Argument exceeds demo limit");return nullptr;}
values[i].resize(length+1);napi_get_value_string_utf8(env,args[i],values[i].data(),values[i].size(),&length);values[i].resize(length);
if(values[i].find('\0')!=std::string::npos){napi_throw_range_error(env,nullptr,"NUL is unsupported by this string bridge");return nullptr;}
}
return Copy(env,kotlincsv_symbols()->kotlin.root.harmony.fileRoundTrip(values[0].c_str(),values[1].c_str()));
}
static napi_value Init(napi_env env,napi_value exports){
napi_property_descriptor properties[]={
{"evaluate",nullptr,Evaluate,nullptr,nullptr,nullptr,napi_default,nullptr},
{"runChecks",nullptr,Check,nullptr,nullptr,nullptr,napi_default,nullptr},
{"fileRoundTrip",nullptr,FileRoundTrip,nullptr,nullptr,nullptr,napi_default,nullptr}};
napi_define_properties(env,exports,3,properties);return exports;
}
static napi_module module={1,0,nullptr,Init,"entry",nullptr,{0}};
extern "C" __attribute__((constructor)) void Register(){napi_module_register(&module);}
4.2 表格、重新编码和文件操作一起接上
工作台把编辑文本、解析行与重新编码分开显示。文件按钮通过应用上下文取得 filesDir,把路径送入 fileRoundTrip;它使用的沙箱路径不是用户在编辑框里随便填写的路径。完整页面还保留了三种模式与失败恢复,能和前面的录屏逐项对照。
先给出 entry/src/main/cpp/types/libentry/index.d.ts,紧接着是完整的 entry/src/main/ets/pages/Index.ets。类型声明与下面的实际调用保持一致。
export const evaluate: (input: string, mode: number) => string;
export const runChecks: () => string;
export const fileRoundTrip: (input: string, path: string) => string;
import native from 'libentry.so';
import { common } from '@kit.AbilityKit';
interface CsvRow { label: string; value: string; metric: number; }
interface CsvResult { ok: boolean; summary: string; rows: CsvRow[]; }
interface CheckResult { passed: number; total: number; detail: string; }
@Entry
@Component
struct Index {
@State input: string = '姓名,备注\n小明,"喜欢摄影,也喜欢徒步"\n小红,"他说""你好"""';
@State mode: number = 0;
@State summary: string = '';
@State ok: boolean = true;
@State records: CsvRow[] = [];
@State encodedText: string = '';
@State roundtrip: string = '';
@State fileResult: string = '尚未执行沙箱文件读写';
@State checked: string = '尚未执行设备检查';
@State details: string = '';
private samples: string[] = ['姓名,备注\n小明,"喜欢摄影,也喜欢徒步"\n小红,"他说""你好"""',
'姓名,备注\n小明,"第一行\n第二行"', '姓名;备注\n小明;摄影', '姓名,备注\n小明,"未闭合'];
private sampleNames: string[] = ['逗号与引号', '字段内换行', '分号表格', '错误引号'];
private modeNames: string[] = ['文本', 'UTF-8 Buffer', '分号方言'];
aboutToAppear(): void { this.readCsv(); }
private readCsv(): void {
const result = JSON.parse(native.evaluate(this.input, this.mode)) as CsvResult;
this.summary = result.summary; this.ok = result.ok;
this.records = result.rows.filter((item: CsvRow) => item.label.startsWith('第') || item.label === '原因');
const encoded: CsvRow | undefined = result.rows.find((item: CsvRow) => item.label === '重新编码');
const round: CsvRow | undefined = result.rows.find((item: CsvRow) => item.label === '写入后重读');
this.encodedText = encoded === undefined ? '' : encoded.value;
this.roundtrip = round === undefined ? '' : round.value;
}
private sample(index: number): void { this.input = this.samples[index]; this.readCsv(); }
private fileRoundTrip(): void {
const context = this.getUIContext().getHostContext() as common.UIAbilityContext;
const result = JSON.parse(native.fileRoundTrip(this.input, context.filesDir + '/csv-roundtrip.csv')) as CsvResult;
this.fileResult = result.summary + ' · ' + result.rows.map((item: CsvRow) => item.value).join(' / ');
}
private runChecks(): void {
const report = JSON.parse(native.runChecks()) as CheckResult;
this.checked = `设备检查 ${report.passed}/${report.total}`; this.details = report.detail;
}
build() {
Column() {
Row({ space: 12 }) {
Text('CSV').fontFamily('monospace').fontSize(25).fontWeight(FontWeight.Bold).fontColor('#50D8B0')
Column({ space: 3 }) {
Text('读写工作台').fontSize(24).fontWeight(FontWeight.Medium).fontColor('#F3F6FA')
Text('一份输入 / 字段预览 / 再编码 / 文件往返').fontSize(12).fontColor('#94A8BB')
}.alignItems(HorizontalAlign.Start)
Blank()
ForEach(this.modeNames, (title: string, index: number) => {
Button(title).id(`mode-${index}`).fontSize(13).borderRadius(5)
.backgroundColor(this.mode === index ? '#50D8B0' : '#26394C')
.fontColor(this.mode === index ? '#10302B' : '#C8D6E5')
.onClick(() => { this.mode = index; this.readCsv(); })
})
}.width('100%').height(90).padding({ left: 26, right: 26 }).backgroundColor('#152435')
Scroll() {
Column({ space: 18 }) {
Row({ space: 20 }) {
Column({ space: 12 }) {
Row() {
Text('原始 CSV').fontSize(17).fontWeight(FontWeight.Bold).fontColor('#20374C')
Blank()
Text('UTF-8').fontFamily('monospace').fontSize(12).fontColor('#7690A4')
}.width('100%')
TextArea({ text: this.input, placeholder: '在这里输入 CSV' }).id('source-input')
.height(245).maxLength(4096).fontSize(17).fontFamily('monospace').backgroundColor('#F0F4F8')
.borderRadius(4).onChange((value: string) => this.input = value)
Flex({ wrap: FlexWrap.Wrap }) {
ForEach(this.sampleNames, (title: string, index: number) => {
Button(title).id(`sample-${index}`).fontSize(12).borderRadius(5)
.backgroundColor('#E7EFF5').fontColor('#365675').margin({ right: 8, bottom: 8 })
.onClick(() => this.sample(index))
})
}.width('100%')
Button('解析并重新编码 →').id('evaluate').width('100%').height(48)
.fontSize(17).fontColor(Color.White).backgroundColor('#226A85').borderRadius(5)
.onClick(() => this.readCsv())
Text('记录中的换行由解析器处理,页面不会先拆行。').fontSize(12).fontColor('#71879C').width('100%')
}.padding(20).layoutWeight(1).backgroundColor(Color.White).borderRadius(7)
Column({ space: 12 }) {
Row() {
Text('记录预览').fontSize(17).fontWeight(FontWeight.Bold).fontColor('#20374C')
Blank()
Text(this.ok ? '解析完成' : '格式错误').fontSize(12).fontColor(this.ok ? '#197D67' : '#B94247')
}.width('100%')
Text(this.summary).id('result-summary').fontSize(19).fontWeight(FontWeight.Medium)
.fontColor(this.ok ? '#226A85' : '#B94247').width('100%')
Row() {
Text('记录 / 列数').fontSize(12).fontColor('#6D8194').width('35%')
Text('字段内容').fontSize(12).fontColor('#6D8194').layoutWeight(1)
}.padding(10).width('100%').backgroundColor('#E7EFF5')
ForEach(this.records, (record: CsvRow) => {
Row({ space: 12 }) {
Text(record.label).fontSize(13).fontFamily('monospace').fontColor('#52728B').width('35%')
Text(record.value).fontSize(17).fontColor(this.ok ? '#263C50' : '#B94247').lineHeight(25)
.layoutWeight(1).copyOption(CopyOptions.LocalDevice)
}.width('100%').padding({ left: 10, right: 10, top: 12, bottom: 12 })
.border({ width: { bottom: 1 }, color: '#E3EBF1' })
})
if (this.roundtrip.length > 0) {
Text('写入后重读:' + this.roundtrip).fontSize(14).fontColor('#197D67').width('100%')
}
}.padding(20).layoutWeight(1).backgroundColor(Color.White).borderRadius(7)
}.width('100%').alignItems(VerticalAlign.Top)
Column({ space: 10 }) {
Row() {
Text('重新编码后的文本').fontSize(16).fontWeight(FontWeight.Medium).fontColor('#AFC5D6')
Blank()
Text('CSV OUTPUT').fontSize(12).fontFamily('monospace').fontColor('#58768D')
}.width('100%')
Text(this.encodedText.length > 0 ? this.encodedText : '等待一份可解析的输入').fontFamily('monospace')
.fontSize(16).fontColor('#E0EDF4').lineHeight(24).width('100%').copyOption(CopyOptions.LocalDevice)
}.width('100%').padding(20).backgroundColor('#1C3042').borderRadius(6)
Row({ space: 18 }) {
Column({ space: 6 }) {
Text('沙箱文件').fontSize(15).fontWeight(FontWeight.Medium).fontColor('#20374C')
Text(this.fileResult).id('file-result').fontSize(14).fontColor('#456C7F').width('100%')
}.layoutWeight(1).alignItems(HorizontalAlign.Start)
Button('写入文件,再读取').id('file-roundtrip').fontSize(14).backgroundColor('#D5EEE8')
.fontColor('#1A6B59').borderRadius(5).onClick(() => this.fileRoundTrip())
}.width('100%').padding(18).backgroundColor('#F6FAFB').borderRadius(6)
Row({ space: 12 }) {
Text(this.checked).fontSize(14).fontColor('#5A7187').layoutWeight(1)
Button('运行原库检查').id('run-checks').fontSize(13).backgroundColor('#DEE8F0')
.fontColor('#365675').borderRadius(5).onClick(() => this.runChecks())
}.width('100%')
if (this.details.length > 0) {
Text(this.details).fontSize(13).fontFamily('monospace').fontColor('#365675').lineHeight(21)
.width('100%').padding(18).backgroundColor('#E3EDF5').borderRadius(5)
}
}.width('100%').constraintSize({ maxWidth: 1320 }).padding(24)
}.width('100%').layoutWeight(1)
}.width('100%').height('100%').backgroundColor('#EAF0F5')
}
}
4.3 源集、依赖和双架构链接配置
共享源码位置是 src/commonMain/kotlin。构建配置把 ARM64 与 X64 分开;每个目标当前需要 2 份显式 KLIB 依赖,文件名中的目标架构与版本都要对应。依赖来源和摘要已保存在平台目录。
harmony/native-config.json 完整内容:
{
"code": "kotlincsv",
"common": "src/commonMain/kotlin",
"dependencies": {
"ohos_arm64": [
"kotlinx-io-core-ohosarm64-0.9.0-1.0.0.klib",
"kotlinx-io-bytestring-ohosarm64-0.9.0-1.0.0.klib"
],
"ohos_x64": [
"kotlinx-io-core-ohosx64-0.9.0-1.0.0.klib",
"kotlinx-io-bytestring-ohosx64-0.9.0-1.0.0.klib"
]
}
}
entry/src/main/cpp/CMakeLists.txt 把生成头文件与同 ABI 动态库配对,链接名称保持为 entry,对应页面的 libentry.so 导入:
cmake_minimum_required(VERSION 3.5.0)
project(harmony_library_example)
set(CMAKE_CXX_STANDARD 17)
if(CMAKE_OHOS_ARCH_ABI STREQUAL "x86_64" OR OHOS_ARCH STREQUAL "x86_64")
set(KMP_ABI x86_64)
else()
set(KMP_ABI arm64-v8a)
endif()
include_directories(${CMAKE_CURRENT_SOURCE_DIR}/include/${KMP_ABI})
add_library(kmp_library SHARED IMPORTED)
set_target_properties(kmp_library PROPERTIES IMPORTED_LOCATION ${CMAKE_CURRENT_SOURCE_DIR}/../../../libs/${KMP_ABI}/libkotlincsv.so)
add_library(entry SHARED napi_init.cpp)
target_link_libraries(entry PUBLIC kmp_library libace_napi.z.so)
应用还需要启用外部 Native 构建,并声明本地接口类型依赖。下面依次是完整的 entry/build-profile.json5 与 entry/oh-package.json5,不包含个人签名:
{
"apiType": "stageMode",
"buildOption": {
"resOptions": {
"copyCodeResource": {
"enable": false
}
},
"externalNativeOptions": {
"path": "./src/main/cpp/CMakeLists.txt",
"abiFilters": [
"arm64-v8a",
"x86_64"
]
}
},
"buildOptionSet": [
{
"name": "release",
"arkOptions": {
"obfuscation": {
"ruleOptions": {
"enable": false,
"files": [
"./obfuscation-rules.txt"
]
}
}
}
}
],
"targets": [
{
"name": "default"
},
{
"name": "ohosTest"
}
]
}
{
"name": "entry",
"version": "1.0.0",
"description": "kotlin-csv · 引号、UTF-8 与读写往返",
"main": "",
"author": "",
"license": "",
"dependencies": {
"libentry.so": "file:./src/main/cpp/types/libentry"
}
}
4.4 从源码生成与页面一致的应用
按前文官方环境说明配置 JDK 17 与 OHOS Kotlin/Native 编译器,把 JAVA_HOME、KOTLIN_NATIVE_HOME 指向对应安装目录。在当前库目录运行下面这份完整脚本;它读取上面的配置,分别生成两个 ABI 的库和头文件,再复制进示例。脚本将公共源码和平台实现编译到明确的 OHOS 目标,并保留每个阶段的构建日志。
完整文件:harmony/build-native.py。
#!/usr/bin/env python3
"""Compile preserved common sources and the OHOS example bridge."""
from pathlib import Path
import argparse,os,json,subprocess,shutil,hashlib
ROOT=Path(__file__).resolve().parent.parent
CONFIG=json.loads((ROOT/'harmony/native-config.json').read_text(encoding='utf8'))
p=argparse.ArgumentParser();p.add_argument('--compiler',default=os.environ.get('KOTLIN_NATIVE_HOME'));p.add_argument('--sdk-native',default=os.environ.get('OHOS_NATIVE_HOME'));p.add_argument('--install');p.add_argument('--check-inputs',action='store_true');args=p.parse_args()
common=sorted((ROOT/CONFIG['common']).rglob('*.kt'));actual=sorted((ROOT/'harmony/src').rglob('*.kt'))
if CONFIG.get('nativeActual'):actual+=sorted((ROOT/CONFIG['nativeActual']).rglob('*.kt'))
extra=sorted((ROOT/'harmony/dependencies/src').rglob('*.kt')) if CONFIG.get('sourceDependency') else []
assert common and actual
if args.check_inputs:
proof=json.loads((ROOT/'harmony/verification.json').read_text(encoding='utf8'))
for path,digest in proof['native']['coreSourceHashes'].items():assert hashlib.sha256((ROOT/path).read_bytes()).hexdigest()==digest
for target,abi in [('ohos_arm64','arm64-v8a'),('ohos_x64','x86_64')]:
for name in CONFIG['dependencies'][target]:assert (ROOT/'harmony/dependencies'/name).is_file(),name
assert hashlib.sha256((ROOT/'example/harmony/entry/libs'/abi/('lib'+CONFIG['code']+'.so')).read_bytes()).hexdigest()==proof['native']['outputs'][target]
print('Shared source, dependencies and both compiled libraries match verification records');raise SystemExit(0)
if not args.compiler:p.error('Set KOTLIN_NATIVE_HOME or pass --compiler')
compiler=Path(args.compiler).resolve();env=dict(os.environ)
def run(target,phase,argv):
dest=ROOT/'harmony/build'/target;dest.mkdir(parents=True,exist_ok=True)
param=dest/(phase+'.args');param.write_text('\n'.join('"'+x.replace('\\','/')+'"' for x in argv),encoding='utf8')
with (dest/(phase+'.log')).open('w',encoding='utf8') as log:q=subprocess.run(['cmd.exe','/d','/c',str(compiler/'bin/konanc.bat'),'@'+str(param)],env=env,stdout=log,stderr=subprocess.STDOUT)
if q.returncode:raise SystemExit(f'{target} {phase} failed; inspect {phase}.log')
print(target,phase,'passed',flush=True)
outputs={}
for target,abi in [('ohos_arm64','arm64-v8a'),('ohos_x64','x86_64')]:
dest=ROOT/'harmony/build'/target;dest.mkdir(parents=True,exist_ok=True);name=CONFIG['code'];libs=[]
for file in CONFIG['dependencies'][target]:libs+=['-library',str(ROOT/'harmony/dependencies'/file)]
if CONFIG.get('utf8proc'):
if not args.sdk_native:p.error('Unicode backend requires --sdk-native or OHOS_NATIVE_HOME')
from build_utf8proc import prepare
libs+=['-library',str(prepare(ROOT,compiler,env,target,Path(args.sdk_native).resolve()))]
opts=['-target',target,'-produce','library','-Xmulti-platform','-Xexpect-actual-classes','-Xcommon-sources='+','.join(map(str,common+extra)),'-opt-in=kotlin.ExperimentalUnsignedTypes']+CONFIG.get('compilerOptions',[])+libs
plugin=['-Xplugin='+str(ROOT/'harmony/dependencies/serialization-plugin.jar')] if CONFIG.get('serialization') else []
if CONFIG.get('splitCore'):
core=['-library',str(dest/(name+'_core.klib'))]
run(target,'compile-core',list(map(str,common+extra+[a for a in actual if a.name!='Bridge.kt']))+opts+plugin+['-o',str(dest/(name+'_core'))])
run(target,'compile-bridge',[str(ROOT/'harmony/src/Bridge.kt'),'-target',target,'-produce','library','-o',str(dest/name)]+plugin+core+libs)
libs+=core
else:run(target,'compile',list(map(str,common+extra+actual))+opts+plugin+['-o',str(dest/name)])
run(target,'link',['-target',target,'-produce','dynamic','-o',str(dest/name),'-Xinclude='+str(dest/(name+'.klib'))]+libs)
outputs[target]=hashlib.sha256((dest/('lib'+name+'.so')).read_bytes()).hexdigest()
if args.install:
project=Path(args.install).resolve();assert (project/'entry/build-profile.json5').is_file()
for source,to in [(dest/('lib'+name+'.so'),project/'entry/libs'/abi/('lib'+name+'.so')),(dest/(name+'_api.h'),project/'entry/src/main/cpp/include'/abi/(name+'_api.h'))]:
to.parent.mkdir(parents=True,exist_ok=True);shutil.copy2(source,to)
(ROOT/'harmony/build/manifest.json').write_text(json.dumps({'sourceFileCount':len(common),'outputs':outputs},indent=2),encoding='utf8')
python harmony/build-native.py --install example/harmony
完成原生构建后,用 DevEco Studio 打开 example/harmony,同步 entry 的类型依赖,为当前应用包名配置自己的开发者签名,再构建和安装 HAP。仅修改 ArkTS 可以重建应用;改过 Kotlin 或平台 actual 时,先重编两个 ABI 的动态库及匹配头文件。
本文的运行记录对应 API 24、OHOS Kotlin/Native 2.2.21-1.1.0,覆盖前面列出的输入与检查。API 26 和其他目标平台没有在这次记录中实测。更多通用三方库资料见 oh-tpc 社区。
5. 第四轮排查:写入文件,还得读回来
Buffer 往返证明了字节处理链路,文件往返则多了一层文件系统。示例由应用上下文取得自己的 filesDir,把文件路径交给 Kotlin 入口。这里使用的是应用沙箱,既不扫描用户目录,也不把设备路径写进文章。打开示例后点击文件操作按钮,返回结果会显示写后读取的行数与内容核对结果。
文件入口先解析输入得到字段列表,再通过原库 writeToFile 写入,随后调用 readAllFromFile 读取同一文件。写入操作会覆盖示例文件,读取到的新列表与原列表相等才返回成功。一个“写入没有抛异常”的结果还不足以代表内容正确,因此这里保留了读取与比较这两步。
fileRoundTrip 的完整定义已经包含在前面的 Bridge.kt 中。下面接入页面时,文件按钮会把应用 filesDir 下的示例文件路径传给这个入口,返回值与普通解析使用相同结构。
上游的文件扩展在内部关闭自己打开的 Source 与 Sink,调用者不用保留已经失效的句柄。通用 Source/Sink 接口则由调用者拥有资源,不能把两者的关闭约定混在一起。若改用外部文件选择器,还要处理应用获得的文件访问权限和引用方式,那是另一条接入路线,不在当前沙箱示例的验证范围里。
6. 运行效果和检查范围
运行环境为 API 24 的华为电脑真机(电脑模式、ARM64)与 x86_64 模拟器。两端安装本次编译的签名应用,完成页面操作和 15 项原库检查,均返回 15/15。下面保留连续操作过程。



十五项内置检查覆盖普通行、引号中的逗号、双引号转义、字段内换行、尾部空字段、中文与补充平面字符、字节往返、字节顺序标记、分号方言、回车换行、未闭合引号、列数不一致、惰性序列重复遍历和错误恢复。文件往返另外通过界面按钮执行,结果与内存检查分别记录,避免把内存 Buffer 通过误写成文件系统已经通过。
页面输入限制适合这次短文本演示,没有做整份大文件全部塞进编辑框的设计。真正导入大表格时,应根据业务选择逐行消费、取消操作和进度反馈;不能因为 API 返回 Sequence,就认为后面的 toList 不会占内存。当前运行效果说明功能路径可用,并不包含大文件吞吐量或内存峰值结论。
7. 总结:确认字段语义,再决定怎么导入
这次接入把 CSV 解析、重新编码、UTF-8 字节处理与沙箱文件往返放在一条可以逐步核对的链路中。原来的共享解析器和 I/O 扩展保留,鸿蒙目标、依赖和调用入口放入 harmony,完整示例位于 example/harmony。每个结果都能沿着相同的数据输入重新检查,遇到坏格式也能继续操作下一份正常资料。
后面做导入页面时,建议先确定分隔符、空值语义和列数错误策略,再设计表格展示。格式规则如果没有说明白,界面画得再像电子表格,也很难知道读到的数据是不是原来的数据。把写后重读留作一个小检查,往往能早点发现引号和换行处理中那些不容易看出的变化。
项目源码:总仓库 · kotlin-csv 适配目录。API 说明见 kotlin-csv 上游 与 kotlinx-io 文档,平台资料见 CPF-KMP-CMP 和 CPF-ApplicationTPC。
更多推荐



所有评论(0)