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 头文件由原生构建脚本输出,不需要手工猜测导出结构。

文件

在调用链中的职责

harmony/src/Bridge.kt

原库调用、返回编码与设备检查

harmony/native-config.json

共享源集、双 ABI 依赖和编译选项

harmony/build-native.py

编译 KLIB、链接动态库并生成匹配头文件

entry/src/main/cpp/napi_init.cpp

参数检查、原生调用与返回值生命周期

entry/src/main/cpp/CMakeLists.txt

按 ABI 选择动态库和头文件

entry/src/main/cpp/types/libentry/index.d.ts

ArkTS 能看到的接口类型

entry/src/main/ets/pages/Index.ets

完整输入、运行反馈与独立界面

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。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐