GEOK 的鸿蒙接入记录
1. 先问清楚区域包含什么
画好一个区域,再判断某个坐标是否落在里面,这是配送范围、园区范围和电子围栏都可能碰到的问题。它看上去只是一个布尔值,真正接入时却会连着冒出几件事:经纬度怎样排列,边界上的点算哪边,多边形要不要首尾闭合,挖掉的内部区域能不能排除。
这次把 GEOK 接到鸿蒙,做了一个区域实验室。左侧是一块围栏画布,右侧单独填写纬度和经度,预设点位也列在旁边。选择坐标后,画布移动测试点,下方同时显示包含判断、区域面积、外环周长和中心坐标。样例固定为一个小矩形,随后加一块内洞,两次使用相同的点。这样一来,差异来自什么地方就很容易看清,不用先接入地图 SDK,也不需要请求定位权限。
最值得记录的结果出现在加洞以后:面积确实减少了,洞内的点却仍被 contains 判为包含。这个现象不该被一句“运行成功”带过去。下面会把输入、源码和结果放到一起,解释它为什么发生,再说鸿蒙构建增加了哪些文件。
2. 实心矩形先确定输入顺序
区域四个角分别取纬度二十二度、经度一百一十四度,以及各自增加零点零一度后的组合。界面输入采用“纬度,经度”,默认点为 22.005,114.005。它落在矩形中央;切到 22.02,114.02,则在区域外侧。另一个按钮直接填入边界顶点,方便检查边界点的处理方式。
这里有一个容易误读的细节:LatLng 的两个构造参数先纬后经,字符串输出却是 [longitude, latitude]。如果把输出原样复制回输入框,两个数会调换位置。例子因此没有直接显示 toString(),而是明确列出纬度和经度。字段说明只要含糊一次,后面的几何计算可能都在处理另一个位置。
构造坐标时,库会检查纬度与经度的取值范围。样例“纬度越界”填入九十一度,界面得到错误提示,没有继续用这个点计算。输入框本身还能遇到少写一个数、混入文字等情况,所以桥接层先检查两个字段并做数字转换,再交给 LatLng。这些属于输入处理,不涉及改写几何算法。
另外,零点零一度是角度跨度,不是固定长度。纬度不同,同样的经度差对应的实际距离也不同。本例选的是局部小范围,面积以平方米、周长以米显示;它并没有拿这组矩形去说明跨日期线区域,也没有借此宣称完成大范围测绘精度验证。
画布使用同一组固定坐标,将外环描成绿色边界,增加内洞时在中间画一块浅黄色区域。蓝色测试点来自输入坐标,点的颜色根据原库返回的“范围内”或“范围外”变化。画布只负责把坐标换成显示位置,没有再做一次点在多边形内的判断。它还标注了“坐标示意,不是地图”,避免把二维示意的比例看成测绘结果。
3. 洞让面积变小,却没有让 contains 变成 false
点击“增加内洞”后,桥接代码给多边形增加一块更小的矩形。它完全在外环内部,默认中心点也位于这块洞中。按常见围栏需求,这个位置通常应该被排除;但当前上游代码没有采用这样的判断。界面因此直接提示“库的 contains 对洞使用 OR,洞内仍返回 true”。
看 Polygon.contains 的条件就能对上:外环包含顶点、洞环包含顶点、点落在外环内部、点落在任意洞内部,这几项之间使用的是逻辑或。只要外环命中,后面是否位于洞内就不会改变结果。鸿蒙桥接调用的是这个方法,所以得到的行为与原实现一致,不是显示层自己补出来的结果。
面积方法走的是另一条路线。它先计算外环面积,再逐个减去洞的面积。因此,同一个多边形的面积和包含判断,对“洞”的解释并不完全相同。仅观察面积变小,很容易顺手认为包含判断也已经扣除了内部区域;把两个结果并排放在界面上,恰好可以防止这种推断。
本次保留了上游共享代码,也将这个行为列进检查项。检查通过所表示的是“已确认当前版本在洞内返回包含”,并不意味着这个行为符合任意围栏产品的业务需求。真的要做禁止进入的洞,应该先明确边界与洞的规则,再针对原实现另行设计修复和回归检查。本例没有悄悄给 contains 加一个过滤层,然后把它描述成原库能力。
4. 运行效果:切换点位与洞,查看十六项检查
演示依次切换右侧列表中的区域中心、外侧点和顶点,画布上的点随坐标改变位置。随后回到中心点并增加内洞,可以同时看到黄色洞区域与包含结果。观察顺序也按这个过程安排:先判断点的位置,再看面积变化,最后读洞的行为提示。GIF 保留了按钮点击到图形与结果更新的过程,静态图片则标出内洞入口和几何检查按钮,方便第一次运行时找到位置。



运行环境为 API 24 的华为电脑真机(电脑模式、ARM64)与 x86_64 模拟器。两端安装本次编译的签名应用,完成页面操作和 16 项原库检查,均返回 16/16。下面保留连续操作过程。
检查页将十六项名称逐个列出,不只是给一个通过数。洞内包含行为也单独列出来,避免把它藏在成功提示中。本文的设备结论只涉及这组样例和断言,没有地图交互、实时定位或长时间运行数据。对局部区域面积和包含规则有更严格需求时,还需要使用业务自己的形状与参考结果继续验证。
5. 沿着 Polygon 读一遍代码
先看形状构造。Polygon 至少需要三个点,接着调用 safeSortedClockwise(),根据各点相对于中心的角度进行排序,并补上闭环末尾的首点。页面显示的“边界自动闭合”就是从 isClosed 读到的。对于这个矩形,自动排序和闭合很方便;如果业务需要保留原始描边顺序,就要先考虑这个构造行为。
再看页面里的中心坐标。虽然 API 名称是 centroid,当前实现实际做的是顶点纬度和经度的算术平均,并在闭环情况下去掉重复的末尾点。它不是按面积计算的严格多边形质心,更没有把洞对中心的影响纳入计算。文章和页面都使用“顶点平均中心”这个称呼,避免名称让人产生额外预期。
周长同样有范围。Polygon.perimeter 取的是外环首尾闭合后的距离和,不把洞环的周长加进去。假如业务需要围栏所有边界的总长度,就不能直接把这个字段当成答案。本例展示面积、外环周长和平均中心,目的正是让这几个看似相近的几何量各自有明确含义。
桥接里的调用片段如下,几何结果都来自 GEOK。字符串格式化只将数值保留到两位小数,界面没有再次计算包含、面积或周长。
下面是 harmony/src/Bridge.kt 的完整内容。rect、hole 给出本次实验的坐标,evaluate 读取纬度和经度,之后分别调用包含判断、面积、周长和中心接口。完整文件保留了含洞返回值的说明以及十六项检查。几何结果由原库计算,后面的 Canvas 只画示意图,不承担点位判断。
package harmony
import me.piruin.geok.*
import me.piruin.geok.geometry.Polygon
import kotlin.math.abs
import kotlin.math.roundToLong
private fun rect()=Polygon(LatLng(22.0,114.0),LatLng(22.0,114.01),LatLng(22.01,114.01),LatLng(22.01,114.0))
private fun hole()=listOf(LatLng(22.003,114.003),LatLng(22.003,114.007),LatLng(22.007,114.007),LatLng(22.007,114.003))
private fun fmt(n: Double)=((n*100).roundToLong()/100.0).toString()
private fun near(a: Double,b: Double)=abs(a-b)<0.000001
fun evaluate(input: String, mode: Int): String = try {
val parts=input.trim().split(',');require(parts.size==2){"输入两个逗号分隔的数:纬度,经度"}
val point=LatLng(parts[0].trim().toDouble(),parts[1].trim().toDouble());val p=rect();if(mode==1)p.addHole(hole())
val contains=p.contains(point)
result(if(mode==0) "实心区域 · 点位"+if(contains) "在内部" else "在外部" else "含洞区域 · 原库 contains="+contains,listOf(
row("判断结果",if(contains) "范围内" else "范围外",if(contains) 1.0 else 0.0),
row("点坐标 · 纬度,经度","${point.latitude}, ${point.longitude}"),
row("区域面积",fmt(p.area())+" m²"),row("外环周长",fmt(p.perimeter)+" m"),
row("顶点平均中心","${p.centroid.latitude}, ${p.centroid.longitude}"),
row("边界自动闭合",p.isClosed.toString()),
row("洞内测试",if(mode==1) "库的 contains 对洞使用 OR,洞内仍返回 true" else "无洞 · 矩形 0.01° × 0.01°")))
} catch(e:Exception){result("坐标输入有误",listOf(row("提示",e.message?:"请检查坐标")),false)}
fun runChecks(): String=checks(listOf(
"矩形中心在内部" to {rect().contains(LatLng(22.005,114.005))},
"矩形外点不包含" to {!rect().contains(LatLng(22.02,114.02))},
"顶点按内部处理" to {rect().contains(LatLng(22.0,114.0))},
"边界自动闭合" to {val p=rect();p.isClosed&&p.boundary.first()==p.boundary.last()},
"矩形面积为正" to {rect().area()>1000000.0&&rect().area()<1300000.0},
"矩形周长范围" to {rect().perimeter>4200.0&&rect().perimeter<4400.0},
"纬度平均中心" to {near(rect().centroid.latitude,22.005)},
"经度平均中心" to {near(rect().centroid.longitude,114.005)},
"输入反序面积一致" to {val p=rect();near(Polygon(p.boundary.open().reversed()).area(),p.area())},
"闭环输入面积一致" to {val p=rect();near(Polygon(p.boundary).area(),p.area())},
"洞会扣除面积" to {val p=rect();val a=p.area();p.addHole(hole());p.area()>0&&p.area()<a},
"洞内包含保留上游语义" to {val p=rect();p.addHole(hole());p.contains(LatLng(22.005,114.005))},
"包围盒包含中心" to {rect().bbox.contains(LatLng(22.005,114.005))},
"少于三个点拒绝" to {try{Polygon(LatLng(1.0,1.0),LatLng(2.0,2.0));false}catch(_:IllegalArgumentException){true}},
"纬度越界拒绝" to {try{LatLng(91.0,0.0);false}catch(_:IllegalArgumentException){true}},
"经度越界拒绝" to {try{LatLng(0.0,181.0);false}catch(_:IllegalArgumentException){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)+"}"
}

6. 鸿蒙构建增加了什么
环境安装可以直接参照 CPF-KMP-CMP 快速开始。本次使用官方鸿蒙 Kotlin/Native 编译器,分别生成面向两种架构的动态库。原仓库的 geok/src/commonMain/kotlin 仍是算法来源,十九份 Kotlin 文件保持原样,构建时对这些文件记录哈希,方便核对源码有没有被修改。
适配内容集中放在 harmony。native-config.json 指定共享源目录、产物名称和两个目标;build-native.py 负责调用编译器,输出动态库与配套的 C API 头文件。src/Bridge.kt 增加用于示例的输入处理、JSON 返回值以及设备检查入口。这里没有第三方运行依赖,也没有把只服务于 JVM 的 Gson 扩展一起带进来。
示例位于 example/harmony。ArkTS 负责输入、模式按钮和结果卡片,N-API 负责把输入送到 Kotlin 导出的接口,并把字符串结果送回来。这样的边界便于阅读:区域算法仍在上游代码,平台调用在桥接,用户操作在页面。后续更换页面外观时,不需要重新实现一套点在多边形内的判断。
构建产物能被链接,仅证明这一步完成;坐标输入、按钮切换和动态库调用,还需要在设备上验证。示例中因此另外保留了 runChecks(),覆盖中心点、外侧点、顶点、闭环、反序输入、洞面积、平均中心和非法坐标等情况。它不是全部上游测试集,但每一项都对应本页实际展示的能力或边界。
把接口定义和应用配置放到一起看,库到页面的调用次序才完整。下表中 entry 路径均位于 example/harmony 中;生成的 C 头文件由原生构建脚本输出,不需要手工猜测导出结构。
| 文件 | 在调用链中的职责 |
|
| 原库调用、返回编码与设备检查 |
|
| 共享源集、双 ABI 依赖和编译选项 |
|
| 编译 KLIB、链接动态库并生成匹配头文件 |
|
| 参数检查、原生调用与返回值生命周期 |
|
| 按 ABI 选择动态库和头文件 |
|
| ArkTS 能看到的接口类型 |
|
| 完整输入、运行反馈与独立界面 |
6.1 源集、依赖和双架构链接配置
共享源码位置是 geok/src/commonMain/kotlin。构建配置把 ARM64 与 X64 分开;每个目标当前需要 0 份显式 KLIB 依赖。没有外部依赖不代表可以使用桌面编译器,目标仍须是 OHOS。
harmony/native-config.json 完整内容:
{
"common": "geok/src/commonMain/kotlin",
"code": "geok",
"dependencies": {
"ohos_arm64": [],
"ohos_x64": []
}
}
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}/libgeok.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": "点位、外环与洞:把几何结果放在一起看",
"main": "",
"author": "",
"license": "",
"dependencies": {
"libentry.so": "file:./src/main/cpp/types/libentry"
}
}
6.2 坐标示意图也要和返回结果对齐
Canvas 把已知区域和当前坐标投到画布上;缩放公式只决定像素位置。面积、周长、中心和 contains 来自 Bridge 返回值,页面没有用画布颜色代替几何判断。下面包含完整 paint、输入同步、区域切换和结果区,绘制尺寸变化也在同一文件中处理。
先给出 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 = '22.005,114.005';
@State latitude: string = '22.005';
@State longitude: string = '114.005';
@State mode: number = 0;
@State selectedSample: number = 0;
@State view: Result = { ok: true, summary: '', rows: [] };
@State checkStatus: string = '16 项几何检查待运行';
@State details: string = '';
private samples: string[] = ['22.005,114.005', '22.02,114.02', '22.0,114.0', '91,114'];
private sampleLabels: string[] = ['区域中心', '区域外侧', '边界顶点', '纬度越界'];
private settings: RenderingContextSettings = new RenderingContextSettings(true);
private ctx: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings);
private canvasWidth: number = 600;
private canvasHeight: number = 350;
aboutToAppear(): void { this.refresh(); }
private refresh(): void {
this.input = `${this.latitude},${this.longitude}`;
this.view = JSON.parse(native.evaluate(this.input, this.mode)) as Result;
this.paint();
}
private chooseSample(index: number): void {
this.selectedSample = index;
const parts = this.samples[index].split(',');
this.latitude = parts[0];
this.longitude = parts[1];
this.refresh();
}
private positionX(longitude: number): number {
return 38 + (longitude - 113.996) / 0.028 * (this.canvasWidth - 76);
}
private positionY(latitude: number): number {
return this.canvasHeight - 36 - (latitude - 21.996) / 0.028 * (this.canvasHeight - 72);
}
private paint(): void {
const c = this.ctx;
const w = this.canvasWidth;
const h = this.canvasHeight;
c.clearRect(0, 0, w, h);
c.fillStyle = '#F8FCF9';
c.fillRect(0, 0, w, h);
c.strokeStyle = '#E2EBE4';
c.lineWidth = 1;
for (let i = 0; i <= 7; i++) {
const x = 38 + i / 7 * (w - 76);
c.beginPath(); c.moveTo(x, 28); c.lineTo(x, h - 36); c.stroke();
const y = 28 + i / 7 * (h - 64);
c.beginPath(); c.moveTo(38, y); c.lineTo(w - 38, y); c.stroke();
}
const x = this.positionX(114);
const y = this.positionY(22.01);
const right = this.positionX(114.01);
const bottom = this.positionY(22);
c.fillStyle = '#D9EEDB';
c.fillRect(x, y, right - x, bottom - y);
c.strokeStyle = '#287B58'; c.lineWidth = 2.5;
c.strokeRect(x, y, right - x, bottom - y);
c.fillStyle = '#245D40'; c.font = '14px sans-serif';
c.fillText('外环 0.01° × 0.01°', x, y - 12);
if (this.mode === 1) {
const hx = this.positionX(114.003);
const hy = this.positionY(22.007);
const hw = this.positionX(114.007) - hx;
const hh = this.positionY(22.003) - hy;
c.fillStyle = '#FFF1D7'; c.fillRect(hx, hy, hw, hh);
c.strokeStyle = '#C17B25'; c.lineWidth = 2;
c.strokeRect(hx, hy, hw, hh);
}
const lat = Number(this.latitude);
const lng = Number(this.longitude);
if (this.view.ok && Number.isFinite(lat) && Number.isFinite(lng)) {
const px = this.positionX(lng);
const py = this.positionY(lat);
c.beginPath(); c.arc(px, py, 7, 0, Math.PI * 2);
c.fillStyle = this.view.rows.length > 0 && this.view.rows[0].value === '范围内' ? '#255CCA' : '#D85E3D';
c.fill();
c.strokeStyle = '#FFFFFF'; c.lineWidth = 2; c.stroke();
c.fillStyle = '#2B3945'; c.font = '13px sans-serif';
c.fillText('测试点', px + 12, py - 10);
}
c.fillStyle = '#77867A'; c.font = '12px sans-serif';
c.fillText('纬度 ↑', 12, 18);
c.fillText('经度 →', w - 84, h - 12);
}
private fieldValue(index: number): string {
return this.view.rows.length > index ? this.view.rows[index].value : '—';
}
@Builder
numberReadout(label: string, index: number) {
Column({ space: 8 }) {
Text(label).fontSize(13).fontColor('#687C6E')
Text(this.fieldValue(index)).id(`result-${index}`).fontSize(22)
.fontWeight(FontWeight.Medium).fontColor('#223F2E').fontFamily('monospace')
}.layoutWeight(1).alignItems(HorizontalAlign.Start).padding(18)
.backgroundColor('#FFFFFF').borderRadius(6)
}
build() {
Scroll() {
Column({ space: 22 }) {
Row() {
Column({ space: 7 }) {
Text('区域实验室').fontSize(30).fontWeight(FontWeight.Bold).fontColor('#203B2C')
Text('GEOK · 一个坐标,四种观察').fontSize(14).fontColor('#688370')
}.alignItems(HorizontalAlign.Start).layoutWeight(1)
Text('LOCAL GEOMETRY').fontSize(11).letterSpacing(2).fontColor('#2E7954')
}.width('100%')
Row({ space: 22 }) {
Column({ space: 12 }) {
Row() {
Text('区域与测试点').fontSize(17).fontWeight(FontWeight.Medium).fontColor('#253F30').layoutWeight(1)
Text(this.mode === 0 ? '实心区域' : '外环 + 内洞').fontSize(13).fontColor('#687E6D')
}.width('100%')
Canvas(this.ctx).id('region-canvas').width('100%').height(350)
.onReady(() => { this.paint(); })
.onAreaChange((oldArea: Area, newArea: Area) => {
this.canvasWidth = Number(newArea.width);
this.canvasHeight = Number(newArea.height);
this.paint();
})
Row({ space: 12 }) {
Text('■ 外环').fontSize(12).fontColor('#287B58')
Text('■ 内洞').fontSize(12).fontColor('#C17B25')
Text('● 测试点').fontSize(12).fontColor('#255CCA')
Blank()
Text('坐标示意,不是地图').fontSize(11).fontColor('#8A948D')
}.width('100%')
Text(this.view.summary).id('summary').fontSize(17).fontWeight(FontWeight.Medium)
.fontColor(this.view.ok ? '#276944' : '#B44C37').width('100%')
}.layoutWeight(1).padding(20).backgroundColor('#FFFFFF').borderRadius(10)
Column({ space: 15 }) {
Text('测试坐标').fontSize(18).fontWeight(FontWeight.Medium).fontColor('#253F30').width('100%')
Row({ space: 8 }) {
Text('纬度').fontSize(13).fontColor('#6A7E70').width(40)
TextInput({ text: this.latitude, placeholder: '纬度' }).id('latitude-input')
.maxLength(4096).fontSize(16).layoutWeight(1).backgroundColor('#F1F6F2')
.onChange((text: string) => { this.latitude = text; this.selectedSample = -1; this.refresh(); })
}.width('100%')
Row({ space: 8 }) {
Text('经度').fontSize(13).fontColor('#6A7E70').width(40)
TextInput({ text: this.longitude, placeholder: '经度' }).id('longitude-input')
.maxLength(4096).fontSize(16).layoutWeight(1).backgroundColor('#F1F6F2')
.onChange((text: string) => { this.longitude = text; this.selectedSample = -1; this.refresh(); })
}.width('100%')
Divider().color('#E7EEE8')
ForEach(this.sampleLabels, (label: string, index: number) => {
Button({ type: ButtonType.Normal }) {
Row() {
Text(label).fontSize(14).fontColor(this.selectedSample === index ? '#255F3F' : '#546B5B')
Blank()
Text(this.samples[index]).fontSize(11).fontFamily('monospace').fontColor('#778D7E')
}.width('100%')
}.id(`sample-${index}`).width('100%').padding(12).borderRadius(5)
.backgroundColor(this.selectedSample === index ? '#E5F2E7' : '#F8FAF8')
.onClick(() => { this.chooseSample(index); })
})
Row({ space: 6 }) {
Button('实心').id('mode-0').layoutWeight(1).fontSize(14)
.backgroundColor(this.mode === 0 ? '#287B58' : '#E8EEE9').fontColor(this.mode === 0 ? '#FFFFFF' : '#4D6756')
.onClick(() => { this.mode = 0; this.refresh(); })
Button('增加内洞').id('mode-1').layoutWeight(1).fontSize(14)
.backgroundColor(this.mode === 1 ? '#B97B2B' : '#E8EEE9').fontColor(this.mode === 1 ? '#FFFFFF' : '#4D6756')
.onClick(() => { this.mode = 1; this.refresh(); })
}.width('100%')
}.width(295).padding(20).backgroundColor('#FFFFFF').borderRadius(10)
}.width('100%').alignItems(VerticalAlign.Top)
Row({ space: 12 }) {
this.numberReadout('区域面积', 2)
this.numberReadout('外环周长', 3)
this.numberReadout('顶点平均中心', 4)
}.width('100%')
Text(this.fieldValue(6)).id('result-6').fontSize(14).lineHeight(22).fontColor(this.mode === 1 ? '#946523' : '#577660')
.width('100%').padding(15).backgroundColor(this.mode === 1 ? '#FFF2DD' : '#E5EFE6').borderRadius(6)
Row({ space: 15 }) {
Text(this.checkStatus).id('check-status').fontSize(14).fontColor('#486452').layoutWeight(1)
Button('运行几何检查').id('run-checks').fontSize(14).backgroundColor('#287B58').onClick(() => {
const checks = JSON.parse(native.runChecks()) as Checks;
this.checkStatus = `设备检查 ${checks.passed}/${checks.total}`;
this.details = checks.detail;
})
}.width('100%')
if (this.details.length > 0) {
Text(this.details).id('check-details').fontSize(12).fontFamily('monospace').lineHeight(22).fontColor('#43604D')
.padding(18).width('100%').backgroundColor('#FFFFFF').borderRadius(6)
}
}.padding(28).width('100%').constraintSize({ maxWidth: 1200 }).alignItems(HorizontalAlign.Start)
}.width('100%').height('100%').backgroundColor('#EDF4EF')
}
}
6.3 坐标与几何量的原生接口
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 "geok_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) geok_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,geok_symbols()->kotlin.root.harmony.evaluate(text.c_str(),mode));
}
static napi_value Check(napi_env env,napi_callback_info info){return Copy(env,geok_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);}
6.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 社区。
7. 总结:先弄清每个几何量的定义
GEOK 的共享实现可以通过鸿蒙 Kotlin/Native 编译并接到 ArkTS 页面,适配中没有改写原有几何代码。这个实验更有用的部分,是把几个 API 的定义摆在一起:闭环会自动补齐,中心是顶点平均,周长只算外环,洞面积会扣除,而洞内点并没有被当前 contains 排除。
对一个小工具来说,把这些行为讲清楚,比只放一张“点在范围内”的截图更实用。以后接入真实地图或业务围栏时,也可以沿用这个验证次序:先确认坐标顺序,再确认边界规则,然后核对几何量,最后考虑定位与交互。
相关源码与示例放在 GEOK 适配目录,更多鸿蒙跨平台资料可从 CPF-KMP-CMP 查阅。
这份区域实验和其他示例一起放在 合集源码 中;应用与三方库共建资料见 CPF-ApplicationTPC。
更多推荐



所有评论(0)