“看到了不少卫星,为什么位置还是不够稳定?”这类问题不能只靠一个卫星数量回答。GeoMarker 的最后一篇建立前台诊断页,同时观察卫星状态和位置结果,把设备能力、权限、订阅、数据字段与清理路径按顺序检查。页面展示证据,不用一个绿色总分掩盖不同来源的信息,也不把没有收到事件显示成零颗卫星。

天空半球与接收器信号通道构成的卫星诊断封面

图1:天空半球与接收器信号通道构成的卫星诊断封面。

一、第一项检查:版本与能力是否匹配

本篇仍采用 API 12 Stage 模型。卫星状态事件的名称是 satelliteStatusChange,订阅回调返回 SatelliteStatusInfo;附加信息数组从 API 12 提供。不要依据自然语言中的“卫星状态”猜接口名,也不要从较新版本示例中直接搬入基线尚未提供的方法。代码通过全局系统能力查询确认设备具备 GNSS 能力,同时仍捕获真正注册时的失败。

能力查询只说明设备声明支持这组接口,不能替代权限检查,也不保证当前环境一定能获得卫星数据。地下空间、遮挡和服务状态都会影响观测;没有目标设备的实际记录时,不应把某个现象直接归因于其中一个因素。诊断页的作用是分开收集证据,帮助缩小范围,而不是凭一项状态自动生成确定的故障结论。

SatelliteStatusInfo 提供上报卫星数及对应数组,附加标记可以描述哪些卫星参与了最近一次位置解算。上报数、参与解算数、最终位置精度是三件事。可见卫星更多不自动等于精度更高,参与解算也不等于它对最终结果的贡献相同;当前代码不尝试从数量推算误差,也不把载噪比等字段变成未经校准的质量评级。

独立复现时新建 GeoMarker,在模块中合并两项前台位置权限和原因资源,添加准入函数。继续系列的工程直接沿用同名文件。诊断只在用户主动开始且应用位于前台时执行,退出即停止;没有必要为了查看一次卫星状态申请后台位置权限或启动长期任务。

// 合并到 entry/src/main/module.json5 的 module 对象,保留模板其余字段。
"requestPermissions": [
  {
    "name": "ohos.permission.APPROXIMATELY_LOCATION",
    "reason": "$string:location_reason",
    "usedScene": { "abilities": ["EntryAbility"], "when": "inuse" }
  },
  {
    "name": "ohos.permission.LOCATION",
    "reason": "$string:location_reason",
    "usedScene": { "abilities": ["EntryAbility"], "when": "inuse" }
  }
]
// 合并到 entry/src/main/resources/base/element/string.json 的 string 数组。
{ "name": "location_reason", "value": "用于你主动发起的前台坐标采样" }

common/LocationGate.ets 的完整内容:

import { abilityAccessCtrl, common, Permissions } from '@kit.AbilityKit';
import { geoLocationManager } from '@kit.LocationKit';

export interface GateResult {
  allowed: boolean;
  approximate: boolean;
  precise: boolean;
  enabled: boolean;
  message: string;
}

export async function requestGate(ctx: common.UIAbilityContext): Promise<GateResult> {
  const names: Permissions[] = [
    'ohos.permission.APPROXIMATELY_LOCATION', 'ohos.permission.LOCATION'
  ];
  const result = await abilityAccessCtrl.createAtManager()
    .requestPermissionsFromUser(ctx, names);
  const ai = result.permissions.indexOf(names[0]);
  const pi = result.permissions.indexOf(names[1]);
  const approximate = ai >= 0 && result.authResults[ai] === 0;
  const precise = pi >= 0 && result.authResults[pi] === 0;
  const enabled = geoLocationManager.isLocationEnabled();
  const message = !approximate ? '位置权限未授予' :
    (!enabled ? '请在系统设置打开位置开关' :
      (precise ? '可采样:精确位置' : '可采样:大致位置'));
  return { allowed: approximate && enabled, approximate: approximate, precise: precise,
    enabled: enabled, message: message };
}

不具备 GNSS 能力并不等于整个应用无法获得任何位置。GeoMarker 的前几篇仍可在设备支持的定位能力和授权范围内工作;本页只关闭卫星诊断入口并解释原因。将一个可选诊断能力的失败扩大成整个产品不可用,会错误地把能力分层变成硬性捆绑,也会让用户无法继续使用已经能够工作的数值与地址功能。

二、第二项检查:字段缺失与零值有没有混淆

附加信息是可选数组,缺失时无法统计解算使用数量,应显示未知。不能写成“没有字段就当作空数组”,否则老设备或未提供该信息的返回会被误判成零颗参与解算。数组存在但长度不足时也按未知处理;这避免把局部数据伪装成完整计数。以下纯函数只统计明确存在的标记,并保留上报总数与统计结果的区别。

附加标记是位集合,不是单选枚举。参与解算的位值为四,因此值为五的元素同时含有其他信息和参与解算标志,也应该计入。用相等判断只统计恰好等于四的值,会漏掉组合标记。位与运算正好表达“是否包含这一位”,而不要求其他位全部为零。卫星附加信息定义是这里的字段依据。

把以下完整控制器保存为 common/GnssProbe.ets。它同时持有卫星、位置和持续错误三种回调。卫星监听用于观察状态,位置订阅用于主动发起前台定位,两者不能混为一个动作。仅注册一个状态监听并不应被解释为已经建立了所需的定位采样任务。

import { geoLocationManager } from '@kit.LocationKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { frontLease } from './FrontLease';

export interface SatelliteSummary { reported: string; used: string; }
export function summarize(info: geoLocationManager.SatelliteStatusInfo): SatelliteSummary {
  const n = info.satellitesNumber;
  if (!Number.isInteger(n) || n < 0) { return { reported: '--', used: '--' }; }
  if (n === 0) { return { reported: '0', used: '0' }; }
  const flags = info.satelliteAdditionalInfo;
  if (flags === undefined || flags.length < n) { return { reported: `${n}`, used: '--' }; }
  let used = 0;
  for (let i = 0; i < n; i++) {
    if (!Number.isInteger(flags[i]) || flags[i] < 0) { return { reported: `${n}`, used: '--' }; }
    if ((flags[i] & geoLocationManager.SatelliteAdditionalInfo.SATELLITES_ADDITIONAL_INFO_USED_IN_FIX) !== 0) {
      used++;
    }
  }
  return { reported: `${n}`, used: `${used}` };
}
export class GnssProbe {
  private active: boolean = false;
  private posOn: boolean = false;
  private satOn: boolean = false;
  private errOn: boolean = false;
  private onSummary: (value: SatelliteSummary) => void;
  private onPosition: (value: geoLocationManager.Location) => void;
  private onFailure: (text: string) => void;
  constructor(summary: (value: SatelliteSummary) => void,
    position: (value: geoLocationManager.Location) => void, failure: (text: string) => void) {
    this.onSummary = summary; this.onPosition = position; this.onFailure = failure;
  }
  private satellite = (value: geoLocationManager.SatelliteStatusInfo): void => {
    if (this.active && frontLease.active) { this.onSummary(summarize(value)); }
  };
  private position = (value: geoLocationManager.Location): void => {
    if (this.active && frontLease.active) { this.onPosition(value); }
  };
  private failure = (code: geoLocationManager.LocationError): void => {
    if (!this.active) { return; }
    this.onFailure(`持续定位错误 ${code};${this.stop()}`);
  };
  start(): void {
    if (this.active) { return; }
    if (!frontLease.active) { throw new Error('当前不在前台'); }
    if (this.posOn || this.satOn || this.errOn) { throw new Error('请先重试停止以完成清理'); }
    if (!canIUse('SystemCapability.Location.Location.Gnss')) { throw new Error('设备不支持GNSS能力'); }
    const request: geoLocationManager.ContinuousLocationRequest = {
      interval: 2, locationScenario: geoLocationManager.UserActivityScenario.SPORT
    };
    try {
      geoLocationManager.on('locationError', this.failure); this.errOn = true;
      geoLocationManager.on('satelliteStatusChange', this.satellite); this.satOn = true;
      geoLocationManager.on('locationChange', request, this.position); this.posOn = true;
      this.active = true;
    } catch (error) {
      const cleanup = this.stop();
      throw new Error(`启动失败 ${(error as BusinessError).code};${cleanup}`);
    }
  }
  stop(): string {
    this.active = false;
    const errors: string[] = [];
    if (this.posOn) {
      try { geoLocationManager.off('locationChange', this.position); this.posOn = false; }
      catch (error) { errors.push(`位置:${(error as BusinessError).code}`); }
    }
    if (this.satOn) {
      try { geoLocationManager.off('satelliteStatusChange', this.satellite); this.satOn = false; }
      catch (error) { errors.push(`卫星:${(error as BusinessError).code}`); }
    }
    if (this.errOn) {
      try { geoLocationManager.off('locationError', this.failure); this.errOn = false; }
      catch (error) { errors.push(`错误:${(error as BusinessError).code}`); }
    }
    return errors.length === 0 ? '已停止' : `待清理 ${errors.join(' / ')}`;
  }
}

上报数为零的完整事件与尚未收到事件不同。前者可以显示零,后者必须保留横线。附加数组中任何将参与统计的元素不是合法非负整数时,统计回到未知,不依靠位运算的隐式转换把异常数据变成零。代码只读取上报数量范围内的标记,额外元素不参与本轮统计,避免数组尾部残留或不一致数据影响结果。

这里把系统字段称为“上报卫星”,有意避免将它扩张成天空中所有可见卫星的总量。数组属于接口当前报告的状态集合,用户肉眼所见的环境、理论星历中的可见性以及接收器实际上报不是同一层概念。诊断文案尽量沿用字段可证明的含义,后续添加说明时也不擅自把数量改成覆盖率或成功率。

三、第三项检查:两路证据是否被强行绑定

卫星事件和位置事件并非一对一返回。一次卫星状态变化未必紧接着一个位置结果,一个位置结果也未必带来新的卫星数组。诊断页因此为两路数据分别保留最后读数,不把它们拼成“这一组卫星产生了这一份坐标”的因果结论。要做更细的关联分析,需要有明确时间依据和采集记录,本例不制造没有对齐依据的关联。

运动场景可能在合适条件下使用 GNSS,也可能在尚未获得稳定 GNSS 位置时得到其他来源结果。位置对象的来源字段是可选项,缺失时显示“未提供”,不根据卫星数量猜测来源。若返回来源码,读者应按 API 12 的枚举定义解释;不能把所有非空位置都标成卫星定位成功,更不能用精度数字倒推出某一种来源。

沿用系列的前台租约与入口文件,确保能力退后台能够触发控制器停止。注册时保存三种稳定函数身份,停止时逐项退订。每项只有成功取消后才清除标志,失败项继续保留并阻止启动新轮次;页面可以再次点击停止重试,不会一边清理失败一边叠加新的监听。

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 { common } from '@kit.AbilityKit';
import { geoLocationManager } from '@kit.LocationKit';
import { GnssProbe, SatelliteSummary } from '../common/GnssProbe';
import { requestGate } from '../common/LocationGate';
import { frontLease } from '../common/FrontLease';

@Entry
@Component
struct Index {
  @State reported: string = '--';
  @State used: string = '--';
  @State precision: string = '--';
  @State source: string = '未提供';
  @State status: string = '尚未开始诊断';
  @State busy: boolean = false;
  @State running: boolean = false;
  private alive: boolean = false;
  private serial: number = 0;
  private probe: GnssProbe = new GnssProbe(
    (value: SatelliteSummary): void => {
      if (this.alive) { this.reported = value.reported; this.used = value.used; }
    },
    (value: geoLocationManager.Location): void => {
      if (!this.alive) { return; }
      this.precision = value.accuracy.toFixed(1);
      this.source = value.sourceType === undefined ? '未提供' : `来源码 ${value.sourceType}`;
    },
    (text: string): void => { if (this.alive) { this.running = false; this.status = text; } });
  private leave = (): void => {
    this.serial++;
    const cleanup = this.probe.stop();
    if (this.alive) { this.busy = false; this.status = cleanup; this.running = false; }
  };
  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 start(): Promise<void> {
    if (this.busy || this.running || !frontLease.active) { return; }
    const serial = ++this.serial; const epoch = frontLease.epoch;
    this.busy = true;
    try {
      const gate = await requestGate(getContext(this) as common.UIAbilityContext);
      if (!this.alive || !frontLease.active || epoch !== frontLease.epoch || serial !== this.serial) { return; }
      if (!gate.allowed) { this.status = gate.message; return; }
      this.probe.start(); this.running = true;
      this.reported = '--'; this.used = '--'; this.precision = '--'; this.source = '未提供';
      this.status = '等待卫星与位置两路事件';
    } catch (error) {
      if (this.alive && serial === this.serial) { this.status = (error as Error).message; }
    } finally { if (this.alive && serial === this.serial) { this.busy = false; } }
  }
  @Builder
  private line(label: string, value: string) {
    Row() {
      Text(label).fontSize(14).fontColor('#73728D')
      Blank()
      Text(value).fontSize(20).fontColor('#383B6E')
    }.height(36).width('100%')
  }
  build() {
    Column({ space: 12 }) {
      Text('卫星诊断').fontSize(26).fontWeight(FontWeight.Bold).height(38)
      Text('GeoMarker · 前台 GNSS').fontSize(13).fontColor('#73728D').height(22)
      Column({ space: 12 }) {
        this.line('上报卫星', this.reported)
        this.line('解算使用', this.used)
        this.line('最近精度(米)', this.precision)
        this.line('位置来源', this.source)
      }.padding(20).height(220).width(316).backgroundColor('#FFFFFF').borderRadius(18)
      Row({ space: 12 }) {
        Button('开始诊断').width(152).height(44).backgroundColor('#5D65A7').fontColor('#FFFFFF')
          .enabled(!this.busy && !this.running).onClick(() => { this.start(); })
        Button('停止诊断').width(152).height(44).backgroundColor('#5D65A7').fontColor('#FFFFFF')
          .onClick(() => { this.leave(); })
      }
      Text(this.status).fontSize(14).fontColor('#5D65A7').height(60).width(316)
      Text('卫星数量不等于定位质量').fontSize(13).fontColor('#73728D').height(24)
    }.padding({ top: 32 }).width('100%').height('100%').backgroundColor('#F1F1FA')
  }
}

卫星诊断页面初始状态示意,尚无事件的字段显示横线

图2:卫星诊断页面初始状态示意,尚无事件的字段显示横线。

初始状态没有任何真实采样数据,所以四项读数中计数和精度使用横线,来源使用未提供。开始以后先重置旧读数,再等待两路事件,避免把上一轮卫星数挂在本轮“等待”状态下。停止后保留最后读数以便查看,但状态明确结束,用户不能因为数值仍然可见就以为采样仍在持续。

图中不加入天空真实方位图,因为当前代码没有绘制方位与高度角;也不把某个假想卫星分布当成目标设备环境。需要天空图时应增加按数组同索引读取的模型和几何转换,并处理数组长度差异。先把当前四个字段的来源与未知状态解释清楚,比先画一张漂亮但无法核对的卫星图更有用。

按能力、授权、事件、标记与位置结果逐层检查的故障定位清单示意

图3:按能力、授权、事件、标记与位置结果逐层检查的故障定位清单示意。

注册失败的业务异常码和持续错误事件的负数枚举,应分别记录来源。能力缺失、参数不合法与运行中的位置开关关闭,需要不同的修复动作;把它们统一映射成“请到室外重试”会让配置问题长期被环境解释掩盖。当前文案保留阶段和原始码,读者可按对应官方表核对,再增加产品化的中文提示。

五、最后检查:用合成标记与设备操作分别验收

纯逻辑部分可构造四颗上报卫星,附加标记依次为四、五、零、四,预期参与解算数量为三;去掉附加数组,预期上报数仍为四而使用数变为未知;数组长度改为三也应未知。再把上报数设为零,预期两个计数均为零。这组数据只检验位集合与缺失值处理,不是某个接收器的观测记录。

设备部分按层检查:先确认权限和位置开关,再开始诊断,观察是否出现注册失败;成功后分别记录有没有卫星事件和位置事件,不要求它们同步到来。关闭开关或撤销权限,检查持续错误与清理提示;恢复条件后重试停止,再发起新轮次。连续点击开始、手动停止、退后台和页面销毁都要验证,退出后不能继续采用旧回调更新页面。

测试未知值时还应保留“从有值变为未知”的一轮。先用完整标记计算出三,再传入缺失数组,结果必须回到横线,不能继续显示上一轮的三。这能发现常见的只在有数据时更新界面、缺失时保持旧值的问题。对来源字段也一样,新位置没有来源信息时应显示未提供,而不是沿用此前的来源码。

清理失败仍是需要设备验证的异常边界:控制器保留未取消标志并停止采用回调,页面在存活期间可重试停止,但这不构成对系统资源已经释放的保证。生产应用如果需要跨页面持续重试失败清理,应把所有者提升到能力级,并单独设计可观察的清理队列。不要为了让示例看起来“总能结束”而吞掉这类错误。

接口与代码已按公开定义静态核对,卫星字段的合成演算可以独立复算,真实 GNSS 行为仍需目标设备验证。至此,六篇连载形成一条完整工程链路:准入说明可访问范围,单次和连续接口提供样本,质量门解释数值,地址通道补充语言描述,卫星诊断保留底层证据。每个环节都知道自己能证明什么,也保留了不能由单一字段推出的结论。

参考资料

Logo

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

更多推荐