前言用手机测量距离这件事很多人第一个想到的是打开地图 App长按起点再长按终点等系统算出一条蓝线。这当然可以但如果只想简单测一下“我离某个地方大概多远”地图的操作链条显得有点长。那能不能自己动手做一个小工具不依赖地图 SDK不用加载复杂的瓦片图层就用系统自带的定位能力拿到当前位置的经纬度再和预设的几个“虚拟地标”算一算球面距离完事。正好 HarmonyOS 的 Location Kit 提供了完整的定位服务从权限申请到坐标获取再到状态监听接口设计得相当清晰。我们用它来实现一个“测量助手”获取当前位置计算到几个虚拟地标的直线距离顺便看看在这个过程中能学到哪些实用的东西。一、项目概览要做什么、怎么测这个小工具的交互逻辑非常简单一句话就能讲清楚打开应用点一下按钮拿到当前位置算出离“地标”有多远展示出来。不需要地图可视化不需要复杂的 UI核心功能就三个环节检查并申请位置权限确保能拿到定位数据调用定位接口获取当前经纬度用 Haversine 公式计算到预设地标的球面距离在界面设计上因为用模拟器测试我们可以把几个预设地标的坐标也做成可配置的方便调试。最终的效果是打开应用系统弹窗请求定位权限同意后点击“开始测量”界面上显示当前位置坐标和到各地标的距离。二、位置服务与权限机制HarmonyOS 将位置信息归类为“半开放隐私信息”这意味着开发者不能像读取设备型号那样直接拿到坐标必须先经过用户的明确授权。2.1 三种定位权限系统提供了三个与定位相关的权限层级不同用途也不同ohos.permission.APPROXIMATELY_LOCATION模糊定位精确度约 5 公里适合只需要城市级位置的场景ohos.permission.LOCATION精准定位精确度在米级需要同时申请模糊定位权限ohos.permission.LOCATION_IN_BACKGROUND后台定位应用切到后台时仍需位置信息时才需要我们的测距工具显然需要精准位置因此在module.json5里需要声明前两个权限。2.2 权限申请流程HarmonyOS 的权限申请分为两步第一步是在配置文件中声明第二步是在运行时动态请求用户授权。这两步缺一不可——只声明不请求用户不会收到弹窗只请求不声明系统直接返回失败。这里有一个容易踩的坑位置开关。即使权限拿到了如果系统设置里的“位置信息”开关是关闭的定位接口也会返回错误码。所以代码里要先调用isLocationEnabled()检查一下提示用户去开开关。2.3 动态申请的实现在EntryAbility的onCreate或onWindowStageCreate阶段拿到上下文后就可以发起权限申请了。核心流程创建AtManager实例检查权限状态如果未授权则调用requestPermissionsFromUser拉起系统弹窗。这部分逻辑放在 Ability 层比较合适因为权限状态会影响到后续所有定位操作早申请早安心。三、获取当前位置权限搞定之后获取坐标这件事就变得非常简单。HarmonyOS 从 API 9 开始废弃了老的system.geolocation统一推荐使用geoLocationManager模块导入路径是kit.LocationKit。3.1 核心接口geoLocationManager提供了多个定位相关的 API最常用的是这几个getCurrentLocation(request, callback)单次定位返回当前位置getLastLocation()获取系统缓存的最近一次位置省电但可能不够“新鲜”on(locationChange, ...)持续监听位置变化适合导航类应用isLocationEnabled()检查系统定位开关状态我们用的是第一种单次定位。虽然getLastLocation更省资源但对于测距这种场景用户期望看到的是“此刻”的距离而不是几分钟前缓存的位置。3.2 配置定位请求参数调用getCurrentLocation时需要传入一个SingleLocationRequest对象它决定了系统用什么样的策略来获取位置。主要参数有两个值得关注locatingPriority定位优先级。选PRIORITY_ACCURACY表示宁可慢一点也要精度高选PRIORITY_LOCATING_SPEED表示优先返回最快的定位结果。locatingTimeoutMs超时时间单位毫秒。超过这个时间还没拿到位置就返回超时错误。对于测距工具精度优先显然更重要所以选用PRIORITY_ACCURACY超时设 10 秒差不多。3.3 坐标系的那些事这里有一个值得注意的技术点geoLocationManager返回的坐标是WGS-84 坐标系——也就是 GPS 原生使用的国际标准坐标系。但如果你后续想把位置标记在 HarmonyOS 的地图组件上会发现点和地图对不上。这是因为华为地图在国内使用的是GCJ-02 坐标系俗称“火星坐标系”两者之间需要做一次转换。我们的测距工具因为不涉及地图可视化直接使用 WGS-84 坐标计算距离完全没有问题。但如果你未来想做“在地图上显示位置”之类的扩展记得用map.convertCoordinate做一次转换否则标记点会偏移几百米。四、Haversine 公式从经纬度到公里数拿到当前位置的经纬度后下一步是计算到目标地标的距离。地球是一个近似的球体不能用平面几何的勾股定理直接算得用球面距离公式。最常用的就是Haversine 公式也叫半正矢公式。4.1 为什么用 HaversineHaversine 公式专门用于计算球面上两点之间的大圆距离——也就是穿过球心的平面与球面相交形成的那段圆弧的长度。它的优点很突出计算简单只需要几次三角函数运算不需要复杂的迭代精度够用对于绝大多数应用场景比如测两地距离、配送范围估算偏差在可接受范围内不依赖外部库纯数学运算不需要网络不需要 SDK当然如果对精度要求极高比如测绘级应用可以用 Vincenty 公式它考虑了地球椭球体的扁率偏差可以控制在厘米级。但对于一个测距小工具Haversine 绰绰有余。4.2 公式拆解Haversine 公式的完整形式是d 2R · arcsin(√a) 其中 a sin²(Δlat/2) cos(lat1) · cos(lat2) · sin²(Δlon/2)参数说明lat1、lon1第一个点的纬度和经度弧度lat2、lon2第二个点的纬度和经度弧度Δlat lat2 - lat1纬度差Δlon lon2 - lon1经度差R 6371地球平均半径单位公里4.3 代码实现把公式翻译成 TypeScript核心代码大约十几行function haversineDistance(lat1: number, lon1: number, lat2: number, lon2: number): number { const R 6371; // 地球平均半径公里 // 角度转弧度 const toRad (deg: number) deg * Math.PI / 180; const φ1 toRad(lat1); const φ2 toRad(lat2); const Δφ toRad(lat2 - lat1); const Δλ toRad(lon2 - lon1); // Haversine 核心计算 const a Math.sin(Δφ / 2) ** 2 Math.cos(φ1) * Math.cos(φ2) * Math.sin(Δλ / 2) ** 2; const c 2 * Math.asin(Math.sqrt(a)); return R * c; }这里有几个小细节值得留意。Math.asin的输入范围是[-1, 1]由于浮点数精度问题a可能略微超出这个范围比如 1.0000000001导致返回NaN。加一个Math.min(a, 1.0)的保护更稳健。另外当两点非常接近时a会很小计算结果在米级也能保持较好的精度。五、代码实现理论部分说完了下面直接上完整可运行的代码。你可以在 DevEco Studio 里新建一个 Empty Ability 项目然后把以下文件内容替换进去。5.1 项目配置首先需要在module.json5中声明权限{ module: { name: entry, type: entry, description: $string:module_desc, mainElement: EntryAbility, deviceTypes: [phone, tablet], deliveryWithInstall: true, installationFree: false, pages: $profile:main_pages, requestPermissions: [ { name: ohos.permission.APPROXIMATELY_LOCATION, reason: $string:location_permission_reason, usedScene: { abilities: [EntryAbility], when: inuse } }, { name: ohos.permission.LOCATION, reason: $string:location_permission_reason, usedScene: { abilities: [EntryAbility], when: inuse } } ], abilities: [ { name: EntryAbility, srcEntry: ./ets/entryability/EntryAbility.ets, description: $string:EntryAbility_desc, icon: $media:layered_image, label: $string:EntryAbility_label, startWindowIcon: $media:startIcon, startWindowBackground: $color:start_window_background, exported: true, skills: [ { entities: [entity.system.home], actions: [action.system.home] } ] } ] } }在resources/base/element/string.json中添加权限说明文案{ string: [ { name: module_desc, value: HarmonyOS 测量助手模块 }, { name: EntryAbility_desc, value: 测量助手 }, { name: EntryAbility_label, value: 测量助手 }, { name: location_permission_reason, value: 需要使用您的位置信息来计算与预设地标之间的距离 } ] }5.2 EntryAbility 中的权限申请/entry/src/main/ets/entryability/EntryAbility.ets的内容如下。这里在 Ability 启动时主动检查并申请权限这样页面加载时权限状态就已经确定了import { UIAbility, Want, AbilityConstant } from kit.AbilityKit; import { hilog } from kit.PerformanceAnalysisKit; import { window } from kit.ArkUI; import { abilityAccessCtrl, bundleManager, Permissions } from kit.AbilityKit; import { BusinessError } from kit.BasicServicesKit; export default class EntryAbility extends UIAbility { onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void { hilog.info(0x0000, MeasureApp, Ability onCreate); // 在 Ability 创建时申请权限 this.requestLocationPermissions(); } onDestroy(): void { hilog.info(0x0000, MeasureApp, Ability onDestroy); } onWindowStageCreate(windowStage: window.WindowStage): void { hilog.info(0x0000, MeasureApp, Ability onWindowStageCreate); windowStage.loadContent(pages/Index, (err) { if (err.code) { hilog.error(0x0000, MeasureApp, Failed to load the content. Cause: %{public}s, JSON.stringify(err) ?? ); return; } hilog.info(0x0000, MeasureApp, Succeeded in loading the content.); }); } onWindowStageDestroy(): void { hilog.info(0x0000, MeasureApp, Ability onWindowStageDestroy); } onForeground(): void { hilog.info(0x0000, MeasureApp, Ability onForeground); } onBackground(): void { hilog.info(0x0000, MeasureApp, Ability onBackground); } private async requestLocationPermissions(): Promisevoid { const permissions: ArrayPermissions [ ohos.permission.APPROXIMATELY_LOCATION, ohos.permission.LOCATION ]; const atManager abilityAccessCtrl.createAtManager(); try { const bundleInfo await bundleManager.getBundleInfoForSelf( bundleManager.BundleFlag.GET_BUNDLE_INFO_WITH_APPLICATION ); const tokenId bundleInfo.appInfo.accessTokenId; for (const permission of permissions) { const grantStatus await atManager.checkAccessToken(tokenId, permission); if (grantStatus abilityAccessCtrl.GrantStatus.PERMISSION_DENIED) { hilog.info(0x0000, MeasureApp, 请求权限: ${permission}); } } await atManager.requestPermissionsFromUser(this.context, permissions); hilog.info(0x0000, MeasureApp, 权限申请完成); } catch (err) { const error err as BusinessError; hilog.error(0x0000, MeasureApp, 权限申请失败: ${error.code}, ${error.message}); } } }5.3 主页面的完整实现/entry/src/main/ets/pages/Index.ets是核心页面包含定位逻辑、距离计算和 UI 展示import { geoLocationManager } from kit.LocationKit; import { BusinessError } from kit.BasicServicesKit; import { promptAction } from kit.ArkUI; // 地标数据接口 interface Landmark { name: string; latitude: number; longitude: number; description: string; iconColor: string; } Entry Component struct Index { // 预设地标请根据实际测试需求修改经纬度 State landmarks: Landmark[] [ { name: 北京天安门, latitude: 39.9042, longitude: 116.4074, description: 首都心脏, iconColor: #E74C3C }, { name: 上海外滩, latitude: 31.2304, longitude: 121.4737, description: 东方明珠, iconColor: #3498DB }, { name: 广州塔, latitude: 23.1059, longitude: 113.3245, description: 小蛮腰, iconColor: #2ECC71 }, { name: 深圳湾, latitude: 22.5136, longitude: 113.9544, description: 科技之窗, iconColor: #F39C12 } ]; State currentLat: string --; State currentLon: string --; State distances: Mapstring, number new Map(); State isLoading: boolean false; State hasLocation: boolean false; State locationTime: string ; // Haversine 距离计算单位公里 private calculateDistance(lat1: number, lon1: number, lat2: number, lon2: number): number { const R 6371; // 地球平均半径公里 const toRad (deg: number): number deg * Math.PI / 180; const φ1 toRad(lat1); const φ2 toRad(lat2); const Δφ toRad(lat2 - lat1); const Δλ toRad(lon2 - lon1); const a Math.sin(Δφ / 2) ** 2 Math.cos(φ1) * Math.cos(φ2) * Math.sin(Δλ / 2) ** 2; // 防止浮点误差导致 a 略大于 1 const c 2 * Math.asin(Math.sqrt(Math.min(a, 1.0))); return R * c; } // 格式化距离显示 private formatDistance(distanceKm: number): string { if (distanceKm 1) { return ${(distanceKm * 1000).toFixed(0)} 米; } return ${distanceKm.toFixed(2)} 公里; } // 获取当前位置 private getCurrentLocation(): void { this.isLoading true; // 1. 检查定位开关 try { const isEnabled geoLocationManager.isLocationEnabled(); if (!isEnabled) { promptAction.showToast({ message: 请先在设置中开启位置服务, duration: 2000 }); this.isLoading false; return; } } catch (err) { console.error(检查定位开关失败: JSON.stringify(err)); } // 2. 配置定位请求 const request: geoLocationManager.SingleLocationRequest { locatingPriority: geoLocationManager.LocatingPriority.PRIORITY_ACCURACY, locatingTimeoutMs: 10000 }; // 3. 发起定位请求 geoLocationManager.getCurrentLocation(request) .then((location) { this.currentLat location.latitude.toFixed(6); this.currentLon location.longitude.toFixed(6); this.hasLocation true; // 记录定位时间 const now new Date(); this.locationTime ${now.getHours().toString().padStart(2, 0)}:${now.getMinutes().toString().padStart(2, 0)}:${now.getSeconds().toString().padStart(2, 0)}; // 计算到各地标的距离 const newDistances new Mapstring, number(); this.landmarks.forEach(landmark { const dist this.calculateDistance( location.latitude, location.longitude, landmark.latitude, landmark.longitude ); newDistances.set(landmark.name, dist); }); this.distances newDistances; this.isLoading false; console.info(定位成功: ${location.latitude}, ${location.longitude}); }) .catch((error: BusinessError) { console.error(定位失败: ${error.code}, ${error.message}); this.isLoading false; let errorMsg 定位失败; if (error.code 601) { errorMsg 定位超时请检查网络或移至开阔区域; } else if (error.code 602) { errorMsg 定位服务不可用; } else if (error.code 3301000) { errorMsg 位置服务未开启; } promptAction.showToast({ message: errorMsg, duration: 2000 }); }); } // 地标卡片构建器 Builder LandmarkCard(landmark: Landmark, index: number) { Column() { Row() { // 左侧图标 Text((index 1).toString()) .fontSize(18) .fontColor(Color.White) .width(36) .height(36) .backgroundColor(landmark.iconColor) .borderRadius(18) .textAlign(TextAlign.Center) // 地标信息 Column() { Row() { Text(landmark.name) .fontSize(16) .fontWeight(FontWeight.Medium) .fontColor(#1A1A1A) Text(· ${landmark.description}) .fontSize(12) .fontColor(#999999) .margin({ left: 6 }) } Text(经纬度: ${landmark.latitude.toFixed(4)}°, ${landmark.longitude.toFixed(4)}°) .fontSize(11) .fontColor(#AAAAAA) .margin({ top: 2 }) } .alignItems(HorizontalAlign.Start) .margin({ left: 12 }) .layoutWeight(1) // 距离显示 Column() { Text(this.distances.has(landmark.name) ? this.formatDistance(this.distances.get(landmark.name)!) : 等待测量) .fontSize(18) .fontWeight(FontWeight.Bold) .fontColor(this.distances.has(landmark.name) ? #007DFF : #CCCCCC) Text(直线距离) .fontSize(10) .fontColor(#AAAAAA) } .alignItems(HorizontalAlign.End) } .width(100%) .padding({ left: 16, right: 16, top: 14, bottom: 14 }) } .width(100%) .backgroundColor(Color.White) .borderRadius(12) .shadow({ radius: 6, color: #10000000, offsetY: 3 }) } build() { Column() { // 标题区 Column() { Text(测量助手) .fontSize(32) .fontWeight(FontWeight.Bold) .fontColor(#1A1A1A) Text(利用位置 API测测你离「虚拟地标」有多远) .fontSize(14) .fontColor(#999999) .margin({ top: 4 }) } .width(100%) .alignItems(HorizontalAlign.Start) .padding({ left: 20, top: 20, bottom: 8 }) // 当前位置卡片 Column() { Row() { Text( 当前位置) .fontSize(14) .fontWeight(FontWeight.Medium) .fontColor(#666666) if (this.hasLocation) { Text( · ${this.locationTime}) .fontSize(12) .fontColor(#AAAAAA) .margin({ left: 6 }) } } .width(100%) Row() { Column() { Text(纬度) .fontSize(11) .fontColor(#AAAAAA) Text(this.currentLat) .fontSize(24) .fontWeight(FontWeight.Bold) .fontColor(this.hasLocation ? #2ECC71 : #CCCCCC) .margin({ top: 4 }) } .alignItems(HorizontalAlign.Start) .layoutWeight(1) Column() { Text(经度) .fontSize(11) .fontColor(#AAAAAA) Text(this.currentLon) .fontSize(24) .fontWeight(FontWeight.Bold) .fontColor(this.hasLocation ? #3498DB : #CCCCCC) .margin({ top: 4 }) } .alignItems(HorizontalAlign.Start) .layoutWeight(1) } .width(100%) .margin({ top: 12 }) Button(this.isLoading ? 测量中... : (this.hasLocation ? 重新测量 : 开始测量)) .fontSize(16) .fontWeight(FontWeight.Medium) .backgroundColor(this.isLoading ? #CCCCCC : #007DFF) .borderRadius(24) .height(48) .width(100%) .margin({ top: 16 }) .enabled(!this.isLoading) .onClick(() { this.getCurrentLocation(); }) } .width(100%) .padding(18) .backgroundColor(Color.White) .borderRadius(16) .shadow({ radius: 8, color: #15000000, offsetY: 4 }) .margin({ left: 16, right: 16, bottom: 16 }) // 地标列表标题 Row() { Text( 虚拟地标) .fontSize(16) .fontWeight(FontWeight.Medium) .fontColor(#1A1A1A) Text(预设坐标可自行修改) .fontSize(11) .fontColor(#AAAAAA) .margin({ left: 6 }) } .width(100%) .padding({ left: 20, top: 8, bottom: 8 }) // 地标列表 List({ space: 12 }) { ForEach(this.landmarks, (landmark: Landmark, index: number) { ListItem() { this.LandmarkCard(landmark, index) } }, (landmark: Landmark) landmark.name) } .width(100%) .padding({ left: 16, right: 16 }) .layoutWeight(1) // 底部说明 Text(Haversine 球面距离 · WGS-84 坐标系) .fontSize(11) .fontColor(#CCCCCC) .padding({ top: 12, bottom: 16 }) } .width(100%) .height(100%) .backgroundColor(#F8F9FA) } }5.4 其他配置文件/entry/src/main/resources/base/profile/main_pages.json保持不变即可默认内容。如果想调整应用名称可以修改string.json中的EntryAbility_label。完整项目结构如下entry/src/main/ ├── ets/ │ ├── entryability/ │ │ └── EntryAbility.ets # 权限申请 │ └── pages/ │ └── Index.ets # 主页面定位 测距 ├── resources/base/ │ ├── element/ │ │ └── string.json # 文案配置 │ └── profile/ │ └── main_pages.json # 页面路由 └── module.json5 # 模块配置含权限声明代码部分就到这里。接下来我们看看在模拟器上怎么跑起来。六、运行展示代码写完后点击 DevEco Studio 右上角的绿色三角按钮编译并安装到模拟器。应用启动后系统会弹出一个权限申请对话框请求获取位置信息。点击“允许”后进入主界面。此时屏幕上方显示“当前位置”纬度和经度都还是“— —”下方是四个预设地标的卡片距离一栏显示“等待测量”。在开始测量之前需要先给模拟器设定一个虚拟位置否则定位接口会因为拿不到 GPS 信号而超时。点击模拟器右侧工具栏的设置按钮三个点在下拉菜单中选择“GPS”在弹出的窗口中切换到“手动设置”页签输入经纬度比如北京纬度 39.9042经度 116.4074点击确定。回到应用点击“开始测量”按钮。大约一两秒后界面上的纬度和经度更新为刚才设置的值下方四个地标卡片右侧的距离数据也同步刷新。因为模拟器定位在北京所以“北京天安门”的距离显示为 0 公里或几十米“上海外滩”大约 1067 公里“广州塔”大约 1889 公里“深圳湾”大约 1941 公里。距离小于 1 公里时单位自动切换为米超过 1 公里显示为公里并保留两位小数。如果想测其他位置可以随时修改模拟器的 GPS 坐标然后点击“重新测量”按钮距离数据会实时更新。整个过程不需要重启应用定位接口每次都会返回最新的模拟位置。七、回顾与收获这个小工具写下来核心代码其实不到 200 行但背后涉及的几个知识点挺有嚼头。第一位置权限的设计哲学。HarmonyOS 把位置信息归为隐私数据通过“配置文件声明 运行时动态请求”两道关卡来保护用户。这种设计思路在整个系统里是一致的——相机、麦克风、通讯录等敏感权限都走这个流程。理解了这个模式以后接入其他敏感能力就能举一反三。第二Location Kit 的能力边界。geoLocationManager不只是能拿一次位置它还支持持续监听、获取历史缓存、逆地理编码、地理围栏等功能。这次只用了冰山一角但整个模块的接口风格是一致的上手一个就能快速掌握其他的。第三球面距离的计算原理。Haversine 公式在数学上不算复杂但它解决了一个实际工程问题——如何在球面上用经纬度算距离。理解了这个公式之后类似的需求比如判断用户是否在某个圆形区域内、计算两个 GPS 点之间的方位角都能迎刃而解。第四模拟器的虚拟定位功能。对于位置类应用的开发和测试模拟器的 GPS 模拟功能非常实用。不需要抱着手机出门跑坐在电脑前就能切换北京、上海、纽约的坐标验证各种地理位置下的逻辑表现。手动设置、轨迹导入、场景模拟三种模式覆盖了从单点测试到连续运动测试的各种需求。总结从权限到定位从坐标到距离整个过程比想象中顺畅。HarmonyOS 的 Location Kit 把位置服务的复杂度封装得相当干净开发者只需要关注业务逻辑不用操心底层定位技术的差异——无论是 GNSS 还是网络定位接口层都是统一的。当然这个小工具还有很多可以扩展的方向。比如把地标列表做成可编辑的让用户自己添加常去的地点加上地理围栏功能当用户接近某个地标时弹个通知或者结合地图组件把当前位置和地标直观地显示在地图上。如果你正好在学 HarmonyOS 开发不妨把这个小工具当作一个练习起点。从权限申请到定位获取再到数据计算和界面展示完整的链路走一遍比看十篇教程都管用。