坐标能用于计算,地址能帮助人理解地点,它们适合不同任务。把逆地理编码失败等同于定位失败,会让一份已经获得的数值结果凭空消失;把可读地址重新解析回坐标,又会引入另一个不确定过程。GeoMarker 第五篇采用双通道展示:原始坐标保持可见,地址作为单独请求得到的补充,语言选择只影响描述通道。

坐标点与空白地址牌并行传递的技术封面

图1:坐标点与空白地址牌并行传递的技术封面。

一、比较两种结果的用途

前四篇的坐标来自位置服务或明确标注的合成输入,算法需要的是数值、精度与顺序。地址则可能包含国家、行政区、道路和门牌等字段,适合让用户辨认地点;这些字段未必全部存在,也不承诺每个坐标都有门牌。它们不应替换计算字段,地址解析成功也不能反向证明原始位置的精度更高。

本篇以 API 12 Stage 模型为基线,用固定的合成坐标零纬度、零经度做完整请求页面。它不主动获取设备位置,因此不申请位置权限;读者接入第二篇已获准的真实坐标时,再沿用那里的授权流程。输入一个明确提供的坐标和读取当前设备位置是不同操作,不能因为两个接口位于同一个模块,就给所有页面附加相同权限申请。

地址解析可能依赖系统提供的服务能力,不能假定每台设备都有同样的结果。先查询能力和服务可用性可以改善提示,但查询为真仍不保证接下来的请求成功。服务状态可能变化,某个输入也可能没有可返回的地址;因此我们保留不可用、空结果、请求失败和有结果四类状态,让产品不必用一个空字符串猜测发生了什么。

语言同样属于请求参数,不是坐标属性。切换中文与英文后,经纬度不变,地址文本可能改变,也可能没有对应语言的描述。示例没有强行添加国家过滤,避免把示例坐标限制到一个不匹配的区域;实际产品需要国家范围时,应基于明确的业务要求配置,而非从用户界面语言臆测用户正在那个国家。

地址文本不适合作为位置记录的唯一标识。同一个点可以因语言、行政名称或服务数据更新而得到不同描述,不同点也可能共享同一条道路名称。应用若要给采样记录编号,应保持自己的稳定标识,再把地址当作随记录展示的属性。把地址字符串作为去重键,往往会同时引入错误合并和重复记录。

二、建立保留坐标的解析契约

新建 GeoMarker 工程并保留模板路由,将以下内容保存为 common/AddressResolver.ets。解析器只接收经纬度和语言,返回地址状态与可读文本,不拿页面上下文,不发起定位,也不改写输入。文档中的地址字段是可选项,因此格式化函数先使用非空详细地址,再按层级合并存在的字段。

import { geoLocationManager } from '@kit.LocationKit';

export interface Coordinate { latitude: number; longitude: number; }
export interface AddressResult { state: string; text: string; }
function readable(address: geoLocationManager.GeoAddress): string {
  const place = address.placeName ?? '';
  if (place.trim().length > 0) { return place; }
  const fields: string[] = [address.countryName ?? '', address.administrativeArea ?? '',
    address.locality ?? '', address.subLocality ?? '', address.roadName ?? '', address.premises ?? ''];
  const parts: string[] = [];
  fields.forEach((value: string) => {
    const text = value.trim();
    if (text.length > 0 && (parts.length === 0 || parts[parts.length - 1] !== text)) {
      parts.push(text);
    }
  });
  return parts.length > 0 ? parts.join(' · ') : '返回结果未提供可读地址';
}
export async function resolveAddress(point: Coordinate, locale: string): Promise<AddressResult> {
  if (!Number.isFinite(point.latitude) || Math.abs(point.latitude) > 90 ||
    !Number.isFinite(point.longitude) || Math.abs(point.longitude) > 180) {
    throw new Error('坐标范围无效');
  }
  if (!canIUse('SystemCapability.Location.Location.Geocoder') ||
    !geoLocationManager.isGeocoderAvailable()) {
    return { state: '服务不可用', text: '保留坐标,稍后重试地址解析' };
  }
  const request: geoLocationManager.ReverseGeoCodeRequest = {
    latitude: point.latitude, longitude: point.longitude, locale: locale, maxItems: 1
  };
  const results = await geoLocationManager.getAddressesFromLocation(request);
  if (results.length === 0) { return { state: '没有地址结果', text: '保留原始坐标' }; }
  return { state: '地址已返回', text: readable(results[0]) };
}

格式化时去除相邻重复字段,是为了避免某些行政层级返回相同名称时出现机械重复;它不是地名标准化算法。代码没有把所有相同词全局去重,因为两个不同层级可能合法地包含同名词,也没有凭空补上“市”“区”等后缀。服务返回缺项时,显示较短的真实文本比猜一个完整门牌更合适。

maxItems 设为一,表示页面只展示单个候选地址。若业务需要比较多个候选,就应保留数组和用户选择过程,不要把多个返回对象的字段拼成一个实际上不存在的地址。这里的简化是可见的产品决策:只有一张地址卡片,原始坐标始终留在另一张卡片里,读者能很容易看出哪部分可以被请求结果替换。

白色 DevEco Studio 中的地址解析函数与双卡片页面布局示意

图2:白色 DevEco Studio 中的地址解析函数与双卡片页面布局示意。

服务不可用时返回可读状态,调用抛错时由外层捕获,两者都不删除数值通道。空数组也是一个有效的服务返回,它不应触发数组越界,更不应自动把坐标改成零。当前示例初始坐标恰好为零是合成输入的选择;如果接入真实点,任何地址失败都必须保留那一份原始点,而非用默认坐标覆盖。

解析器的降级文案也应保留事实层次。“没有地址结果”表示本次返回为空,不代表坐标非法;“服务不可用”表示当前服务条件不足,不代表这个世界上不存在该地点。两种情况都适合保留坐标,但后续动作不同:前者可以让用户接受纯数值记录,后者可以稍后主动重试。统一显示“未知地点”会丢掉这项判断依据。

三、比较前台请求与页面展示的寿命

逆地理编码同样是异步调用,用户可能在请求完成前离开页面。沿用第二篇的前台租约,将下列文件加入工程,入口能力在前后台变化时更新代次。这里不建立持续监听,也不在退出时虚构系统取消接口;应用只保证旧请求的结果不会落到新的前台页面状态上。

common/FrontLease.ets 保存前台租约:

export class FrontLease {
  active: boolean = false;
  epoch: number = 0;
  private stops: Array<() => void> = [];
  enter(): void { this.active = true; }
  leave(): void {
    this.active = false;
    this.epoch++;
    this.stops.forEach((stop: () => void) => stop());
  }
  add(stop: () => void): void { this.stops.push(stop); }
  remove(stop: () => void): void {
    this.stops = this.stops.filter((item: () => void) => item !== stop);
  }
}
export const frontLease = new FrontLease();

将模板的 entryability/EntryAbility.ets 替换为:

import { UIAbility } from '@kit.AbilityKit';
import { window } from '@kit.ArkUI';
import { frontLease } from '../common/FrontLease';

export default class EntryAbility extends UIAbility {
  onWindowStageCreate(stage: window.WindowStage): void {
    stage.loadContent('pages/Index');
  }
  onForeground(): void { frontLease.enter(); }
  onBackground(): void { frontLease.leave(); }
  onDestroy(): void { frontLease.leave(); }
}

页面在开始解析时复制输入坐标。如果未来添加点选记录,复制可以防止用户切换记录后,尚未完成的请求读取到已经变化的对象。结果仍需要与请求身份一起校验,因为复制只固定输入,并没有阻止旧结果回到页面。输入快照和结果代次解决的是不同环节,不能认为有其中之一就自动覆盖所有异步问题。

地址语言在请求开始时一并确定,请求中禁用两个按钮,避免同时存在两种语言的在途结果。这个小页面选择串行交互,容易解释也容易验证;如果以后要允许快速切换语言,应把语言和点标识纳入请求身份。不要只根据返回文本的字符猜测它对应哪种语言,更不能在结果到来后套用当前页面的新语言标签。

页面明确由按钮触发地址解析,不将前一篇每个连续位置回调都转成一次地址请求。坐标变化频率和人类阅读地点描述的频率往往不同,逐点解析可能产生大量已经没有展示价值的任务。后续产品可以只对用户选中的点解析,或采用经过定义的空间与时间节流;不能只凭一句“实时地址”就忽略请求数量和失效结果的处理。

四、给两条通道各自的可见位置

import { BusinessError } from '@kit.BasicServicesKit';
import { Coordinate, resolveAddress } from '../common/AddressResolver';
import { frontLease } from '../common/FrontLease';

@Entry
@Component
struct Index {
  private point: Coordinate = { latitude: 0, longitude: 0 };
  @State address: string = '尚未解析';
  @State language: string = '中文';
  @State status: string = '点击按钮解析合成坐标';
  @State busy: boolean = false;
  private alive: boolean = false;
  private serial: number = 0;
  private leave = (): void => {
    this.serial++;
    if (this.alive) { this.busy = false; this.status = '已离开前台,旧解析不再展示'; }
  };
  aboutToAppear(): void { this.alive = true; frontLease.add(this.leave); }
  onPageHide(): void { this.leave(); }
  aboutToDisappear(): void {
    this.alive = false; this.leave(); frontLease.remove(this.leave);
  }
  private async resolve(locale: string): Promise<void> {
    if (this.busy || !frontLease.active) { return; }
    const id = ++this.serial;
    const epoch = frontLease.epoch;
    this.busy = true; this.address = '解析中';
    this.language = locale === 'zh' ? '中文' : '英文';
    const snapshot: Coordinate = { latitude: this.point.latitude, longitude: this.point.longitude };
    try {
      const result = await resolveAddress(snapshot, locale);
      if (!this.alive || !frontLease.active || id !== this.serial || epoch !== frontLease.epoch) { return; }
      this.address = result.text; this.status = result.state;
    } catch (error) {
      if (this.alive && id === this.serial && epoch === frontLease.epoch) {
        this.address = '保留原始坐标';
        const code = (error as BusinessError).code;
        this.status = code !== undefined ? `地址解析失败 ${code}` : (error as Error).message;
      }
    } finally { if (this.alive && id === this.serial) { this.busy = false; } }
  }
  build() {
    Column({ space: 12 }) {
      Text('坐标与地址').fontSize(26).fontWeight(FontWeight.Bold).height(38)
      Text('GeoMarker · 合成坐标示例').fontSize(13).fontColor('#707C68').height(22)
      Column({ space: 8 }) {
        Text('原始坐标 · WGS84').fontSize(14).fontColor('#707C68').height(24)
        Text(`纬度  ${this.point.latitude.toFixed(6)}`).fontSize(20).fontColor('#304B27').height(32)
        Text(`经度  ${this.point.longitude.toFixed(6)}`).fontSize(20).fontColor('#304B27').height(32)
      }.padding(16).height(152).width(316).backgroundColor('#FFFFFF').borderRadius(18)
      Column({ space: 8 }) {
        Text(`地址语言 · ${this.language}`).fontSize(14).fontColor('#707C68').height(24)
        Text(this.address).fontSize(18).fontColor('#304B27').height(68).width(284)
          .maxLines(3).textOverflow({ overflow: TextOverflow.Ellipsis })
      }.padding(16).height(132).width(316).backgroundColor('#FFFFFF').borderRadius(18)
      Row({ space: 12 }) {
        Button('解析中文').width(152).height(44).backgroundColor('#55734B').fontColor('#FFFFFF')
          .enabled(!this.busy).onClick(() => { this.resolve('zh'); })
        Button('解析英文').width(152).height(44).backgroundColor('#55734B').fontColor('#FFFFFF')
          .enabled(!this.busy).onClick(() => { this.resolve('en'); })
      }
      Text(this.status).fontSize(14).fontColor('#55734B').height(44).width(316)
      Text('地址为空时,坐标仍然有效').fontSize(13).fontColor('#707C68').height(24)
    }.padding({ top: 26 }).width('100%').height('100%').backgroundColor('#F1F5ED')
  }
}

坐标与地址页面初始状态示意,原始合成坐标与尚未解析的地址分开呈现

图3:坐标与地址页面初始状态示意,原始合成坐标与尚未解析的地址分开呈现。

页面的原始坐标卡片不随地址请求进入忙碌状态;请求期间只替换地址卡片和状态说明。用户仍能看到输入是什么,这有助于排查“解析了哪个点”这种常见问题。地址字段限制行数只影响显示,不改变解析器返回的完整文本;如果产品需要复制或分享地址,应从数据值读取,而不是从截断后的可见字符串拼装。

两个按钮表达的是语言请求,没有“定位成功”或“地图已就绪”这样的额外承诺。固定零坐标可能返回空结果,也可能受到系统默认地区或服务实现影响;文章不指定一个虚构地址作为必然输出。首次页面显示“尚未解析”,是无需网络与设备结果就能到达的状态,插图因此准确对应代码的初始值。

数值通道与地址通道在用途、依赖和失败策略上的对比示意

图4:数值通道与地址通道在用途、依赖和失败策略上的对比示意。

对比图将数值通道与描述通道放在不同列。前者保留输入、提供算法依据;后者依赖解析服务、提供语言描述。地址服务失效时可以降级为坐标展示,定位请求本身失效时则不能用地址文本补造一个新坐标。两个方向不是对称的,这也是页面需要两张独立卡片而不是一个“当前位置”大字符串的原因。

固定输入页没有读取当前设备位置,但这不意味着解析服务必然完全在本地完成。界面描述应陈述“解析这个坐标”,不要添加未经核实的离线保证。真实接入时,用户应能知道正在处理哪个采样点;如果将来扩展批量解析或共享结果,还要重新审视请求范围,而不是把一个按钮的授权含义扩张到整段历史记录。

五、验证可用、空值和失效分支

验证先从最容易复现的初始状态开始,检查合成坐标是否显示六位小数,地址是否为“尚未解析”。点击中文解析,观察服务不可用、空结果或地址返回中的实际分支,确认无论哪种分支,原始数值卡片都不改变。再点击英文解析,检查语言标签与本次请求一致,不预设两种语言一定得到两条完全不同的地址。

对解析器可以单独输入非法纬度或非有限值,预期在发起服务请求之前拒绝。对格式化逻辑可以构造只有道路、只有国家、详细地址空白以及相邻层级重复的对象,确认不会输出“undefined”或多余分隔符。这里验证的是应用的字符串规则,不能把构造对象的输出当成系统服务在某地真实返回的数据。

对语言切换的验收还应检查失败之后的标签。中文请求失败后再次发起英文请求,地址卡片应显示本轮正在解析,而不能继续挂着旧中文结果却标成英文。当前代码在开始时清空地址文本并更新语言,避免这种短暂但容易被截图保留的错配。若产品希望保留旧地址,必须额外展示它的语言和历史状态。

再请求后退出到后台并返回,确认旧结果不能覆盖新状态;服务错误时保留异常码,空数组则显示明确的“没有地址结果”。本篇代码按公开接口做静态核对,尚未在目标设备验证地理编码服务可用性。系列的主体至此已能解释准入、单次与连续位置、样本质量以及人类可读地址;最后一篇将加入卫星状态诊断,进一步区分“看见了什么信号”和“得到了什么位置”。

参考资料

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐