把一份背包数据装进 NBT:KNBT 在鸿蒙上的标签与压缩实验
1. 开场:数字还在,类型怎么不见了
一个背包里有名称、等级、生命值和物品列表。如果只是想把它显示在页面上,普通文本已经够用。但把数据保存下来,再交给另一个程序读取,问题就换了一种问法:生命值原来是短整数,开关原来是一个字节,余额用了长整数,这些区别经过一次编码和解码之后还能不能保留?
NBT 是一种带类型的二进制标签格式,SNBT 则让这些标签能够用文字表达。读起来有些像熟悉的键值数据,但 20s、1b、9007199254740993L 的后缀有实际含义。这里选用 KNBT,把它的公共解析和编码实现接到鸿蒙,做一个可以修改数据、切换压缩方式、查看标签与字节的检查页面。
这次不做游戏存档编辑器,也不连接服务器。范围限定为同一份标签在文本、二进制与压缩包装之间的往返。打开应用,左侧可以输入背包数据,右侧显示规范化文本;切到“字节”页签,又能看见二进制前二十四个字节。先让这个小过程跑通,格式的限制和平台缺口就比较容易找出来。

2. 一段文本,先回答三个问题
默认内容包含中文名称、普通整数、短整数和字符串列表。它有意把几种类型放在一起,便于检查读取后是否仍然对应。比如生命值的短整数后缀仍在,物品列表也保持原来的顺序,就比单看一句“解析成功”更有说服力。
第一步是把输入交给 StringifiedNbt,得到 NbtTag。第二步用二进制 Nbt 编码,再解码回来。第三步比较前后的标签,并用开启 prettyPrint 的文本格式重新输出。页面显示的“往返一致”来自标签比较,不是拿输入文本和输出文本逐字对照;空格和换行变化不会被误判为数据丢失。
这里遇到的第一个实际问题来自二进制根标签。直接把带有多个字段的 Compound 交给编码器,会收到“根 Compound 只支持一个入口”的错误。这和压缩方式无关,即使选择 None 也一样。库把根标签名表示成外层 Compound 中的一个键,因此桥接层需要先给业务标签包一层有名字的入口。
示例把这个名字固定成 payload,写入时包装,读回时取出。界面仍然显示用户输入的那份业务标签,二进制里则保留必要的根标签结构。这个调整只发生在新增桥接里,公共编码器保持上游原样。如果改成读取外部文件,应先弄清文件的根名称约定,不能把示例里的固定名称直接当成所有文件的标准。
下面是 harmony/src/Bridge.kt 的完整内容。这里需要同时保留两种格式:StringifiedNbt 解析文字,Nbt 写入二进制。encodeTag 为业务标签补上有名称的根入口,decodeTag 再取回业务内容;十六项检查验证标签和压缩包装。下面是包含全部辅助方法的完整文件,压缩平台实现另在下一段给出。
package harmony
import kotlinx.serialization.*
import net.benwoodworth.knbt.*
import net.benwoodworth.knbt.internal.asGzipSource
import okio.*
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):String = "{\"label\":"+quote(label)+",\"value\":"+quote(value)+",\"metric\":0}"
private fun result(summary:String,rows:List<String>,ok:Boolean=true):String = "{\"ok\":$ok,\"summary\":"+quote(summary)+",\"rows\":"+rows.joinToString(",","[","]")+"}"
private val textFormat = StringifiedNbt { prettyPrint = true }
private fun format(mode:Int):Nbt = Nbt {
variant = NbtVariant.Java
compression = when(mode) { 1 -> NbtCompression.Gzip; 2 -> NbtCompression.Zlib; else -> NbtCompression.None }
}
private fun parse(input:String):NbtTag = StringifiedNbt.decodeFromString(NbtTag.serializer(), input)
private fun encodeTag(nbt:Nbt,tag:NbtTag):ByteArray =
nbt.encodeToByteArray(NbtTag.serializer(),NbtCompound(mapOf("payload" to tag)))
private fun decodeTag(nbt:Nbt,bytes:ByteArray):NbtTag =
(nbt.decodeFromByteArray(NbtTag.serializer(),bytes) as NbtCompound).getValue("payload")
private fun roundTrip(tag:NbtTag,mode:Int):NbtTag {
val nbt=format(mode)
return decodeTag(nbt,encodeTag(nbt,tag))
}
fun evaluate(input:String,mode:Int):String = try {
val tag=parse(input)
val nbt=format(mode)
val bytes=encodeTag(nbt,tag)
val decoded=decodeTag(nbt,bytes)
val compression=when(mode){1->"Gzip";2->"Zlib";else->"None"}
result("$compression · ${bytes.size} 字节 · 往返${if(tag==decoded) "一致" else "不一致"}",listOf(
row("标签类型",if(tag is NbtCompound) "Compound · ${tag.size} 个字段" else tag::class.simpleName?:"NbtTag"),
row("规范化 SNBT",textFormat.encodeToString(NbtTag.serializer(),decoded)),
row("二进制前 24 字节",bytes.take(24).joinToString(" "){it.toUByte().toString(16).padStart(2,'0')}),
row("文本与二进制往返",if(tag==decoded) "标签和值保持一致" else "结果不同")))
} catch(e:Exception) { result("输入无法读取",listOf(row("错误原因",e.message?:"NBT parse error")),false) }
private fun fails(action:()->Unit):Boolean=try{action();false}catch(_:Exception){true}
fun runChecks():String {
val cases=listOf<Pair<String,()->Boolean>>(
"None 往返" to {val t=parse("{name:\"小猫\",level:3}");roundTrip(t,0)==t},
"Gzip 往返" to {val t=parse("{name:\"小猫\",level:3}");roundTrip(t,1)==t},
"Zlib 往返" to {val t=parse("{name:\"小猫\",level:3}");roundTrip(t,2)==t},
"Unicode 文本" to {roundTrip(NbtString("猫猫 🐈"),1)==NbtString("猫猫 🐈")},
"长整型精度" to {roundTrip(NbtLong(9007199254740993L),2)==NbtLong(9007199254740993L)},
"空 Compound" to {roundTrip(buildNbtCompound{},0)==buildNbtCompound{}},
"整数数组" to {val t=NbtIntArray(intArrayOf(-1,0,255));roundTrip(t,2)==t},
"SNBT 规范化幂等" to {val t=parse("{n:1b,s:hello}");parse(textFormat.encodeToString(NbtTag.serializer(),t))==t},
"Gzip 头标记" to {NbtCompression.detect(encodeTag(format(1),NbtString("abc")))==NbtCompression.Gzip},
"Zlib 头标记" to {NbtCompression.detect(encodeTag(format(2),NbtString("abc")))==NbtCompression.Zlib},
"拒绝错误 SNBT" to {fails{parse("{name:")}},
"拒绝异类 List" to {fails{parse("[1,hello]")}},
"跨压缩缓冲块" to {val t=NbtString("0123456789".repeat(1600));roundTrip(t,1)==t},
"截断 Gzip 报错" to {fails{val data=encodeTag(format(1),NbtString("hello")); val source=Buffer().write(data.copyOf(data.size-4)).asGzipSource();source.buffer().use { it.readAll(Buffer()) }}},
"损坏校验报错" to {fails{val data=encodeTag(format(1),NbtString("hello"));data[data.size-8]=(data[data.size-8].toInt() xor 0x40).toByte();val source=Buffer().write(data).asGzipSource();source.buffer().use{it.readAll(Buffer())}}},
"反复压缩与释放" to {val t=NbtString("repeat");(0 until 40).all{roundTrip(t,it%3)==t}}
)
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)+"}"
}
None、Gzip 和 Zlib 是二进制内容的三种包装选择。切换按钮时,页面重新调用原生入口,而不是用一个固定比例修改字节数。短内容压缩后有可能比不压缩更长,因为压缩头、结束信息和校验也占空间。这个页面只报告当前内容实际产生的字节数,没有据此推断大文件的压缩率。
3. 鸿蒙缺的那层,刚好在压缩入口
KNBT 的公共代码已经包含文本解析、标签类型和二进制读写。真正与平台相关的是四个压缩接口:Source 的 Gzip、Zlib 读取包装,以及 Sink 的 Gzip、Zlib 写入包装。其他平台通过自己的实际实现完成这部分;鸿蒙需要补上对应的 actual。
环境搭建直接参考 CPF-KMP-CMP 快速开始。本次使用鸿蒙 Kotlin/Native 2.2.21-1.1.0、JDK 17 和 API 24 SDK,分别生成 ARM64 与 X64 产物。序列化核心与 Okio 使用对应的 OHOS 发行依赖,版本、来源及摘要保留在源码目录,避免出现“同名依赖能下载,目标架构却对不上”的情况。
新增的 CompressionOhos 放在 harmony/src 中,通过工具链已有的 platform.zlib 绑定调用系统压缩接口。这一层沿用 Okio 的 Source、Sink 抽象,调用者仍然用原来的流接口。Gzip 和 Zlib 的包装参数分别传入三十一和十五;两者使用同一压缩引擎,但头部和校验格式不同,不能靠更改文件名互相替代。
harmony/src/CompressionOhos.kt 完整实现如下。四个 actual、初始化、分块读写、合法结束与资源释放都在这份文件中,不能只留下最上面的四个转发方法。
@file:OptIn(kotlinx.cinterop.ExperimentalForeignApi::class, ExperimentalUnsignedTypes::class)
package net.benwoodworth.knbt.internal
import kotlinx.cinterop.*
import okio.*
import platform.posix.memset
import platform.zlib.*
internal actual fun Source.asGzipSource(): Source = OhosInflateSource(this, 31)
internal actual fun Source.asZlibSource(): Source = OhosInflateSource(this, 15)
internal actual fun Sink.asGzipSink(level: Int): Sink = OhosDeflateSink(this, level, 31)
internal actual fun Sink.asZlibSink(level: Int): Sink = OhosDeflateSink(this, level, 15)
private const val CHUNK = 4096
private fun zError(code: Int, stream: z_stream): IOException =
IOException("zlib $code: ${stream.msg?.toKString() ?: "compressed stream error"}")
private class OhosInflateSource(private val source: Source, windowBits: Int) : Source {
private val stream = nativeHeap.alloc<z_stream>()
private val input = nativeHeap.allocArray<UByteVar>(CHUNK)
private val output = nativeHeap.allocArray<UByteVar>(CHUNK)
private var closed = false
private var finished = false
private var inputEnded = false
init {
memset(stream.ptr, 0, sizeOf<z_stream>().convert())
val code = inflateInit2_(stream.ptr, windowBits, zlibVersion()?.toKString(), sizeOf<z_stream>().toInt())
if (code != Z_OK) {
val error = zError(code, stream)
nativeHeap.free(input); nativeHeap.free(output); nativeHeap.free(stream.ptr)
throw error
}
}
override fun timeout(): Timeout = source.timeout()
override fun read(sink: Buffer, byteCount: Long): Long {
check(!closed) { "Source is closed" }
require(byteCount >= 0) { "Negative byte count" }
if (byteCount == 0L) return 0L
if (finished) return -1L
while (true) {
if (stream.avail_in == 0u && !inputEnded) {
val buffer = Buffer()
val count = source.read(buffer, CHUNK.toLong())
if (count == -1L) inputEnded = true
else {
if (count == 0L) throw IOException("Compressed source made no progress")
val bytes = buffer.readByteArray(count)
for (i in bytes.indices) input[i] = bytes[i].toUByte()
stream.next_in = input; stream.avail_in = count.toUInt()
}
}
val capacity = minOf(byteCount, CHUNK.toLong()).toUInt()
stream.next_out = output; stream.avail_out = capacity
val before = stream.avail_in
val code = inflate(stream.ptr, Z_NO_FLUSH)
val written = capacity - stream.avail_out
if (code != Z_OK && code != Z_STREAM_END && code != Z_BUF_ERROR) throw zError(code, stream)
for (i in 0 until written.toInt()) sink.writeByte(output[i].toInt())
if (code == Z_STREAM_END) finished = true
if (written > 0u) return written.toLong()
if (finished) return -1L
if (inputEnded && stream.avail_in == 0u) throw EOFException("Truncated compressed stream")
if (before == stream.avail_in && before > 0u) throw IOException("Invalid compressed stream")
}
}
override fun close() {
if (closed) return
closed = true
try { source.close() }
finally {
inflateEnd(stream.ptr)
nativeHeap.free(input); nativeHeap.free(output); nativeHeap.free(stream.ptr)
}
}
}
private class OhosDeflateSink(private val sink: Sink, level: Int, windowBits: Int) : Sink {
private val stream = nativeHeap.alloc<z_stream>()
private val input = nativeHeap.allocArray<UByteVar>(CHUNK)
private val output = nativeHeap.allocArray<UByteVar>(CHUNK)
private var closed = false
init {
memset(stream.ptr, 0, sizeOf<z_stream>().convert())
val code = deflateInit2_(stream.ptr, level, Z_DEFLATED, windowBits, 8, Z_DEFAULT_STRATEGY,
zlibVersion()?.toKString(), sizeOf<z_stream>().toInt())
if (code != Z_OK) {
val error = zError(code, stream)
nativeHeap.free(input); nativeHeap.free(output); nativeHeap.free(stream.ptr)
throw error
}
}
override fun timeout(): Timeout = sink.timeout()
private fun emit(flush: Int): Int {
stream.next_out = output; stream.avail_out = CHUNK.toUInt()
val code = deflate(stream.ptr, flush)
if (code != Z_OK && code != Z_STREAM_END && code != Z_BUF_ERROR) throw zError(code, stream)
val buffer = Buffer()
val count = CHUNK - stream.avail_out.toInt()
for (i in 0 until count) buffer.writeByte(output[i].toInt())
if (count > 0) sink.write(buffer, count.toLong())
return code
}
override fun write(source: Buffer, byteCount: Long) {
check(!closed) { "Sink is closed" }
require(byteCount >= 0) { "Negative byte count" }
if (source.size < byteCount) throw EOFException("Insufficient source bytes")
var left = byteCount
while (left > 0) {
val count = minOf(left, CHUNK.toLong()).toInt()
val bytes = source.readByteArray(count.toLong())
for (i in bytes.indices) input[i] = bytes[i].toUByte()
stream.next_in = input; stream.avail_in = count.toUInt()
do { emit(Z_NO_FLUSH) } while (stream.avail_in > 0u)
left -= count
}
}
override fun flush() {
check(!closed) { "Sink is closed" }
do { emit(Z_SYNC_FLUSH) } while (stream.avail_out == 0u)
sink.flush()
}
override fun close() {
if (closed) return
closed = true
try { while (emit(Z_FINISH) != Z_STREAM_END) {} }
finally {
deflateEnd(stream.ptr)
nativeHeap.free(input); nativeHeap.free(output); nativeHeap.free(stream.ptr)
sink.close()
}
}
}
另外两份新增文件负责平台构建:native-config 描述源目录、依赖和编译选项,build-native 读取它后编译公共实现与平台实际实现,再链接动态库和生成头文件。上游三十七份公共源码逐文件核对,保持固定提交原样。需要修改的是平台入口和示例调用方式,而不是重新写一遍 NBT 解析器。
example/harmony 保存完整应用。CMake 根据 ABI 选择动态库和匹配头文件,N-API 接收输入、调用 Kotlin 导出函数、复制返回值并释放原生字符串,ArkTS 负责输入与排版。原生库与头文件必须成对更新;只替换一份库,容易留下调用者和导出接口不一致的问题。
把接口定义和应用配置放到一起看,库到页面的调用次序才完整。下表中 entry 路径均位于 example/harmony 中;生成的 C 头文件由原生构建脚本输出,不需要手工猜测导出结构。
| 文件 | 在调用链中的职责 |
|
| 原库调用、返回编码与设备检查 |
|
| 共享源集、双 ABI 依赖和编译选项 |
|
| 编译 KLIB、链接动态库并生成匹配头文件 |
|
| 参数检查、原生调用与返回值生命周期 |
|
| 按 ABI 选择动态库和头文件 |
|
| ArkTS 能看到的接口类型 |
|
| 完整输入、运行反馈与独立界面 |
3.1 NBT 返回值与原生字符串释放
N-API 先核对两个参数,再按 UTF-8 字节数分配缓冲区。Kotlin 返回的字符串先复制到 ArkTS 值中,随后调用 DisposeString 释放。下面给出整个文件,包含模块注册;只有 Evaluate 函数而没有 Init 和 Register,页面就无法从 libentry.so 取得接口。
完整文件:example/harmony/entry/src/main/cpp/napi_init.cpp。
#include "napi/native_api.h"
#include "knbt_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) knbt_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,knbt_symbols()->kotlin.root.harmony.evaluate(text.c_str(),mode));
}
static napi_value Check(napi_env env,napi_callback_info info){return Copy(env,knbt_symbols()->kotlin.root.harmony.runChecks());}
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}};
napi_define_properties(env,exports,2,properties);return exports;
}
static napi_module module={1,0,nullptr,Init,"entry",nullptr,{0}};
extern "C" __attribute__((constructor)) void Register(){napi_module_register(&module);}
3.2 源集、依赖和双架构链接配置
共享源码位置是 src/commonMain。构建配置把 ARM64 与 X64 分开;每个目标当前需要 2 份显式 KLIB 依赖,文件名中的目标架构与版本都要对应。依赖来源和摘要已保存在平台目录。 序列化插件与 core KLIB 配套,压缩 actual 则使用 OHOS 工具链已有的 zlib 平台绑定。
harmony/native-config.json 完整内容:
{
"code": "knbt",
"common": "src/commonMain",
"dependencies": {
"ohos_arm64": [
"kotlinx-serialization-core-ohosarm64-1.9.1-1.0.0.klib",
"okio-ohosarm64-3.16.4-1.0.0.klib"
],
"ohos_x64": [
"kotlinx-serialization-core-ohosx64-1.9.1-1.0.0.klib",
"okio-ohosx64-3.16.4-1.0.0.klib"
]
},
"serialization": true,
"compilerOptions": [
"-opt-in=net.benwoodworth.knbt.InternalNbtApi",
"-opt-in=kotlin.contracts.ExperimentalContracts"
]
}
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}/libknbt.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": "knbt · 文本、二进制与两种压缩",
"main": "",
"author": "",
"license": "",
"dependencies": {
"libentry.so": "file:./src/main/cpp/types/libentry"
}
}
3.3 SNBT 与字节两个视图共用一次往返
标签预览和十六进制预览都读取同一次原生返回。压缩按钮改变 mode 后重新编码,字节页签只切换呈现方式,不重复修改标签。完整文件包括错误输入、三种包装、两个预览页签和设备检查,读者能直接对应开头的背包实验。
先给出 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;
import native from 'libentry.so';
interface ResultRow { label: string; value: string; metric: number; }
interface Result { ok: boolean; summary: string; rows: ResultRow[]; }
interface Checks { passed: number; total: number; detail: string; }
@Entry
@Component
struct Index {
@State input: string = '{name:"小猫",level:3,health:20s,items:["地图","指南针"]}';
@State mode: number = 0;
@State pane: number = 0;
@State view: Result = { ok: true, summary: '', rows: [] };
@State checkStatus: string = '还未检查';
@State details: string = '';
private samples: string[] = ['{name:"小猫",level:3,health:20s,items:["地图","指南针"]}',
'{name:"背包",counts:[I;1,2,3],enabled:1b}', '{name:"玩家",money:9007199254740993L}', '{name:'];
private labels: string[] = ['背包标签', '数组和字节', '长整数', '错误标签'];
private modes: string[] = ['None', 'Gzip', 'Zlib'];
aboutToAppear(): void { this.refresh(); }
private refresh(): void { this.view = JSON.parse(native.evaluate(this.input, this.mode)) as Result; }
private value(index: number): string { return this.view.rows.length > index ? this.view.rows[index].value : ''; }
private bytes(): string[] { return this.value(2).split(' ').filter((v: string) => v.length > 0); }
@Builder
compressionControls() {
Row({ space: 10 }) {
ForEach(this.modes, (label: string, index: number) => {
Button(label).id(`mode-${index}`).height(38).fontSize(14)
.backgroundColor(this.mode === index ? '#385A44' : '#E9EEE9')
.fontColor(this.mode === index ? '#FFFFFF' : '#385A44')
.onClick(() => { this.mode = index; this.refresh(); })
})
}
}
build() {
Scroll() {
Column({ space: 22 }) {
Row({ space: 12 }) {
Column({ space: 5 }) {
Text('标签打包间').fontSize(32).fontWeight(FontWeight.Bold).fontColor('#23382B')
Text('读一份背包数据,保留每个标签的类型').fontSize(15).fontColor('#6D7B70')
}.alignItems(HorizontalAlign.Start).layoutWeight(1)
Text('NBT / SNBT').fontFamily('monospace').fontSize(16).fontColor('#385A44')
.padding(12).backgroundColor('#E7EEE5').borderRadius(8)
}.width('100%')
Row({ space: 22 }) {
Column({ space: 16 }) {
Text('01 文本标签').fontSize(17).fontWeight(FontWeight.Medium).fontColor('#23382B').width('100%')
TextArea({ text: this.input, placeholder: '{name:"小猫",level:3}' }).id('source-input')
.maxLength(4096).fontSize(18).fontFamily('monospace').height(215).backgroundColor('#F4F7F2')
.borderRadius(10).onChange((text: string) => { this.input = text; this.refresh(); })
Grid() {
ForEach(this.samples, (sample: string, index: number) => {
GridItem() {
Button(this.labels[index]).id(`sample-${index}`).width('100%').fontSize(14)
.backgroundColor('#FFFFFF').fontColor('#47644E').border({ width: 1, color: '#D7E2D7' })
.onClick(() => { this.input = sample; this.refresh(); })
}
})
}.columnsTemplate('1fr 1fr').columnsGap(10).rowsGap(10).height(100).width('100%')
Text('02 选择二进制包装').fontSize(17).fontWeight(FontWeight.Medium).fontColor('#23382B').width('100%')
this.compressionControls()
Text(this.view.summary).id('summary').fontSize(20).fontWeight(FontWeight.Bold)
.fontColor(this.view.ok ? '#385A44' : '#B14432').width('100%')
Text(this.value(0)).fontSize(14).fontColor('#6D7B70').width('100%')
}.padding(24).backgroundColor('#FFFFFF').borderRadius(18).layoutWeight(4).alignItems(HorizontalAlign.Start)
Column({ space: 14 }) {
Row({ space: 12 }) {
Text('03 打开检查包装').fontSize(17).fontWeight(FontWeight.Medium).fontColor('#E0ECDD').layoutWeight(1)
Button('标签').fontSize(13).backgroundColor(this.pane === 0 ? '#629575' : '#334439').fontColor('#FFFFFF')
.onClick(() => { this.pane = 0; })
Button('字节').fontSize(13).backgroundColor(this.pane === 1 ? '#629575' : '#334439').fontColor('#FFFFFF')
.onClick(() => { this.pane = 1; })
}.width('100%')
if (this.pane === 0) {
Text(this.view.ok ? this.value(1) : this.value(0)).id('tag-preview').fontFamily('monospace')
.fontSize(18).fontColor(this.view.ok ? '#DDECDD' : '#FFB7A9').width('100%').lineHeight(30)
} else {
Text('前 24 个字节 / 十六进制').fontSize(13).fontColor('#9AAF9F').width('100%')
Grid() {
ForEach(this.bytes(), (byte: string, index: number) => {
GridItem() {
Text(byte).fontFamily('monospace').fontSize(17).fontColor('#DDECDD')
.padding(12).backgroundColor(index < 3 ? '#466E54' : '#334439').borderRadius(6)
}
}, (byte: string, index: number) => `${index}:${byte}`)
}.columnsTemplate('1fr 1fr 1fr 1fr 1fr 1fr').rowsGap(8).columnsGap(8).height(240).width('100%')
}
Blank().height(14)
Text(this.value(3)).fontSize(14).fontColor('#AFC7B7').width('100%')
}.padding(24).backgroundColor('#24332A').borderRadius(18).layoutWeight(5).alignItems(HorizontalAlign.Start)
.constraintSize({ minHeight: 455 })
}.width('100%').alignItems(VerticalAlign.Top)
Row({ space: 14 }) {
Text('在当前设备上检查数据往返').fontSize(15).fontColor('#536858').layoutWeight(1)
Text(this.checkStatus).id('check-status').fontSize(16).fontWeight(FontWeight.Medium).fontColor('#385A44')
Button('检查标签与压缩').id('run-checks').fontSize(14).backgroundColor('#385A44').fontColor('#FFFFFF')
.onClick(() => {
const checks = JSON.parse(native.runChecks()) as Checks;
this.checkStatus = `设备检查 ${checks.passed}/${checks.total}`;
this.details = checks.detail;
})
}.width('100%').padding(18).backgroundColor('#E8EFE5').borderRadius(12)
if (this.details.length > 0) {
Text(this.details).id('check-details').fontSize(13).fontFamily('monospace').fontColor('#385A44')
.width('100%').lineHeight(22).padding(18).backgroundColor('#FFFFFF').borderRadius(12)
}
}.width('100%').constraintSize({ maxWidth: 1240 }).padding(28)
}.height('100%').width('100%').backgroundColor('#F3F6EE')
}
}
3.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 社区。
4. 能解出正文,还不能提前宣布压缩流有效
压缩读取按四千零九十六字节分块。底层输入耗尽时,不能直接把它当成正常结束:压缩流可能只是被截断了。实现必须观察 Z_STREAM_END;尚未到达结束状态却再无输入,就返回截断错误。否则一个只剩半截的文件,也可能因为前面的标签已经读出来而被误当成有效数据。
校验信息通常位于压缩流末尾,因此专门的损坏检查会完整消费解压 Source,而不是在读出一个标签之后就结束。示例构造一份正常 Gzip 数据,分别删掉末尾字节和翻转校验字节,确认两种情况下都能得到异常。这两个检查用于验证压缩包装;它们没有把错误文件显示成一份成功标签。
写入时同样需要认真处理结束。普通 write 只负责持续输入,close 才用 Z_FINISH 把压缩尾部写完。资源清理放在 finally 中,随后释放压缩状态和 Native 缓冲区。关闭状态单独记录,重复 close 不再重复释放。读取时请求零字节也直接返回零,遵守流接口的约定,不额外推动解压状态。
这些细节不容易从一张成功截图看出来,所以检查中还放了超过一个缓冲块的长字符串和连续四十次压缩往返。它们用于发现分块处理和结束路径的问题。反复运行通过并不等于做过长期内存剖析,文章也不据此给出泄漏率或吞吐量结论。

5. 再试两份数据:大整数与错误列表
长整数预设使用九千零七万亿以上的一个值,超出 JavaScript 常见安全整数范围。它在 SNBT 中以带 L 后缀的文字输入,Kotlin 解析为 NbtLong,重新显示时继续保留长整数标签。页面没有先把它转换成 ArkTS Number,所以这个展示路径不会在进入原库之前把末位精度丢掉。
这不意味着应用里所有数值都自动获得了长整数能力。如果业务代码自行把返回文字转换为 Number,仍然需要承担该类型的精度边界。此处返回的是用于检查的规范化 SNBT;要给其他业务使用,应该根据标签类型设计接口,而不是从显示文字里随意截取一个数字。
数组预设包含整数数组和字节值。整数数组与普通 List 的标签不同,重新输出后还能从前缀辨认。异类 List 则用于失败检查:把整数与字符串混装在一个列表里,解析器会拒绝。示例不在页面里把它们统一转成字符串来“修好”,因为那样已经改变原始类型了。
最后一组预设故意留下未完成的键值结构。输入区仍然保留错误内容,右侧显示读取原因;重新选择正常背包后,标签与往返结果恢复。这个过程比只显示一次成功结果更接近日常编辑:用户输入到一半时出现错误很正常,下一次修改应该能够重新得到正确输出。
6. 两端运行记录,与这次能够确认的范围
运行环境为 API 24 的华为电脑真机(电脑模式、ARM64)与 x86_64 模拟器。两端安装本次编译的签名应用,完成页面操作和 16 项原库检查,均返回 16/16。下面保留连续操作过程。


实测操作包含从 Gzip 切到 Zlib、读取数组与字节预设、触发错误输入、再恢复正常背包。最后执行十六项设备内检查,覆盖三种包装的往返、Unicode 文本、长整数、空 Compound、整数数组、规范化幂等、包装头识别、语法拒绝、跨缓冲块数据、截断与校验损坏以及反复释放。检查真正调用本次编译的原库和新增平台流实现。
这份记录确认的是 Java 变体、小规模输入和列出的检查。没有测试所有游戏版本的存档,也没有比较不同压缩等级的性能。页面预览只展示前二十四个字节,完整数据仍然在编码过程中参与往返比较;字节预览较短,不代表文件只包含这些内容。
收尾时最值得记住的是两处约定:二进制根名称需要显式包装,压缩流需要读到合法结束。前者决定一份标签怎样进入文件,后者决定一份文件是否完整。把这两处写清楚,再保留类型、错误与资源释放,后续扩展文件导入或更大的数据读取才有可靠的起点。
完整源码位于 鸿蒙跨平台三方库合集 的 KNBT 目录,包含 harmony 平台实现与 example/harmony 应用。标签 API 参考 KNBT 上游说明,压缩状态参考 zlib 手册,平台共建资料见 CPF-KMP-CMP 与 CPF-ApplicationTPC。原库许可和版权随源码保留。
更多推荐



所有评论(0)