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

文件

在调用链中的职责

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

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

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。

Logo

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

更多推荐