这是一份连续采样功能的实现笔记,按设计迭代记录每轮应该检查的内容,不把未执行的设备操作写成开发经历。GeoMarker 前一篇用按钮获取一份坐标;这一篇让按钮开启一个有明确寿命的订阅。需求只有一句话:用户停留在前台并主动开始时接收位置,点击停止或离开前台时释放订阅,回来以后由用户决定是否重新开始。

可拆连接件与连续样本链构成的前台订阅封面

图1:可拆连接件与连续样本链构成的前台订阅封面。

一、第一轮:把“持续”写进请求类型

工程仍以 API 12 Stage 模型为基线。ContinuousLocationRequest 用秒表示请求间隔,场景字段选择运动采样;它与上一章单次请求的毫秒时限属于不同语义,不应共用一个叫作“时间”的配置变量。本例间隔写成二秒,表示请求的上报节奏,并不保证系统每隔整整二秒准时交付一次坐标。界面直接写“请求间隔”,避免让用户误会这是实际采样仪表的保证频率。

运动场景适合主动观察位置变化,但不等于所有设备和环境都会返回卫星位置。系统定位来源、权限范围、环境条件都会影响结果。当前页面只显示接收数和最近精度,暂不计算总路程,也不保存坐标序列。这样可以先验证订阅是否有重复注册、停止是否完整,再在下一篇引入质量门,避免把采样生命周期错误与距离算法错误混在一起。

独立新建 GeoMarker 时,在 module.json5 与字符串资源合并下列片段,添加准入函数。继续阅读系列的读者沿用同名文件。权限只声明使用期间有效,代码不申请后台定位,也没有后台任务服务;前台需求应首先通过明确的退出路径实现,而不是遇到后台失败就增加更强权限。

// 合并到 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 };
}

本轮没有用定时器反复调用单次定位。定时器只决定应用何时再次发出请求,无法替代位置服务本身的持续订阅契约;当前一次调用尚未结束时,下一次定时触发还会叠加请求。持续接口则给出了与请求对应的退订操作,更适合这个明确需要开始和停止的页面。选择接口时先看生命周期要求,再考虑按钮外观是否相似。

二、第二轮:给注册与退订同一个主人

连续定位有两类错误渠道:注册动作可以立即失败,注册以后也可能报告持续定位错误。只在开始按钮外面套一个异常捕获,会漏掉开关关闭、权限变化或网络访问问题等运行过程事件。本例把位置订阅和错误监听放在同一个控制器中,由它保存回调函数身份和注册完成标志。位置服务文档说明了两种事件以及对应退订接口。

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

export class LocationStream {
  private active: boolean = false;
  private positionOn: boolean = false;
  private errorOn: boolean = false;
  private accept: (value: geoLocationManager.Location) => void;
  private report: (text: string) => void;
  constructor(accept: (value: geoLocationManager.Location) => void,
    report: (text: string) => void) {
    this.accept = accept; this.report = report;
  }
  private position = (value: geoLocationManager.Location): void => {
    if (this.active && frontLease.active) { this.accept(value); }
  };
  private failure = (code: geoLocationManager.LocationError): void => {
    if (!this.active) { return; }
    const cleanup = this.stop();
    this.report(`持续定位错误 ${code};${cleanup}`);
  };
  start(): void {
    if (this.active) { return; }
    if (!frontLease.active) { throw new Error('当前不在前台'); }
    if (this.positionOn || this.errorOn) { throw new Error('先重试停止,完成清理'); }
    const request: geoLocationManager.ContinuousLocationRequest = {
      interval: 2, locationScenario: geoLocationManager.UserActivityScenario.SPORT
    };
    try {
      geoLocationManager.on('locationError', this.failure);
      this.errorOn = true;
      geoLocationManager.on('locationChange', request, this.position);
      this.positionOn = true;
      this.active = true;
    } catch (error) {
      const cleanup = this.stop();
      throw new Error(`注册失败 ${(error as BusinessError).code};${cleanup}`);
    }
  }
  stop(): string {
    this.active = false;
    let message = '已停止';
    if (this.positionOn) {
      try {
        geoLocationManager.off('locationChange', this.position);
        this.positionOn = false;
      } catch (error) { message = `位置退订失败 ${(error as BusinessError).code}`; }
    }
    if (this.errorOn) {
      try {
        geoLocationManager.off('locationError', this.failure);
        this.errorOn = false;
      } catch (error) { message += `;错误监听退订失败 ${(error as BusinessError).code}`; }
    }
    return message;
  }
}

回调函数必须作为稳定成员保存。停止时重新写一个内容相同的箭头函数,得到的仍是不同对象,不能保证退掉原来的监听。本例每一个退订都显式传入注册时的函数,不使用省略回调的“清空所有订阅”做法;这样后续同一应用增加其他位置使用者时,也不会被这个页面误删监听。

注册顺序先准备错误通道,再发起位置订阅。若第二步失败,控制器会尝试清理已经成功的第一步。两个注册标志只有对应操作成功后才更新,退订失败时保留标志,并拒绝启动新的订阅。这个选择比不管成功与否都标记“已关闭”更保守,却能防止故障恢复过程中重复堆积监听;停止按钮允许再次尝试清理。

控制器把“还接受回调”与“系统登记是否清理完成”分开。停止先关闭接收门,即使退订失败,晚到位置也不再进入页面;随后逐项退订,错误会出现在状态文本里。因此“页面已停收”不能直接等同“系统资源已释放”。排查时要同时看清理结果,不能只盯着接收数停止增长就宣布资源没有残留。

这里的双标志不是为了给每个字段都加状态,而是为了表达部分成功。假设错误监听注册成功、位置注册失败,此时只需要清理前者;假设位置退订失败、错误监听取消成功,下次停止只重试位置退订。把这两种情况压成单一“运行中”会丢失事实,随后的重试就无法知道应当补做哪一步。

三、第三轮:把前台寿命接到入口能力

本轮沿用第二篇的前台租约。入口能力负责宣布进入与离开,页面负责登记自己的停止函数。租约不会自动开始定位,它只通知当前订阅应该收尾。将下面两份文件加入工程,入口类名称仍与模块配置中的 EntryAbility 对齐;页面从模板路由加载,其他资源无需改变。

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 { requestGate } from '../common/LocationGate';
import { frontLease } from '../common/FrontLease';
import { LocationStream } from '../common/LocationStream';

@Entry
@Component
struct Index {
  @State count: number = 0;
  @State precision: string = '--';
  @State running: boolean = false;
  @State busy: boolean = false;
  @State status: string = '尚未开始';
  private alive: boolean = false;
  private serial: number = 0;
  private stream: LocationStream = new LocationStream(
    (value: geoLocationManager.Location): void => {
      if (this.alive) { this.count++; this.precision = value.accuracy.toFixed(1); }
    },
    (text: string): void => {
      if (this.alive) { this.running = false; this.status = text; }
    });
  private leave = (): void => {
    this.serial++;
    if (this.alive) { this.busy = false; }
    this.stop();
  };
  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 stop(): void {
    const text = this.stream.stop();
    if (this.alive) { this.status = text; this.running = false; }
  }
  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.stream.start(); this.running = true;
      this.count = 0; this.precision = '--';
      this.status = gate.precise ? '前台采样中' : '前台大致位置采样中';
    } catch (error) {
      if (this.alive && serial === this.serial) { this.status = (error as Error).message; }
    } finally { if (this.alive && serial === this.serial) { this.busy = false; } }
  }
  build() {
    Column({ space: 12 }) {
      Text('连续采样').fontSize(26).fontWeight(FontWeight.Bold).height(38)
      Text('GeoMarker · 前台订阅').fontSize(13).fontColor('#63778A').height(22)
      Column({ space: 8 }) {
        Text('已接收').fontSize(14).fontColor('#63778A').height(24)
        Text(`${this.count} 个位置`).fontSize(30).fontColor('#1C4264').height(48)
        Text(`最近精度  ${this.precision} 米`).fontSize(17).fontColor('#1C4264').height(32)
        Text('请求间隔 2 秒 · SPORT').fontSize(14).fontColor('#63778A').height(24)
      }.padding(20).height(216).width(316).backgroundColor('#FFFFFF').borderRadius(18)
      Row({ space: 12 }) {
        Button('开始采样').width(152).height(44).backgroundColor('#2669A6').fontColor('#FFFFFF')
          .enabled(!this.busy && !this.running).onClick(() => { this.start(); })
        Button('停止采样').width(152).height(44).backgroundColor('#2669A6').fontColor('#FFFFFF')
          .onClick(() => { this.leave(); })
      }
      Text(this.status).fontSize(15).fontColor('#2669A6').height(60).width(316)
      Text('返回后台会停止,回来后手动开始').fontSize(13).fontColor('#63778A').height(24)
    }.padding({ top: 32 }).width('100%').height('100%').backgroundColor('#EDF4FA')
  }
}

连续采样页面初始状态示意,接收计数为零且尚未开始

图2:连续采样页面初始状态示意,接收计数为零且尚未开始。

接收数表示通过当前订阅入口的回调数量,不代表有效路段数,更不代表用户走过多少步。每次成功开始一轮采样时计数清零,停止后保留本轮最后读数,便于用户核对。最近精度也只是最后一个样本的字段,尚未经过第四篇的质量门;页面没有把它渲染为“定位质量优秀”的评级。

LocationError 是持续定位事件使用的枚举,和注册时的业务异常码不是同一个错误空间。状态文字保留“持续定位错误”前缀,避免将负数枚举拿去查业务错误码表。示例收到持续错误后选择停止整轮采样,这是产品策略,适合一个小型前台实验页;若将来改成可恢复状态,需要另外定义哪些错误允许等待恢复,不能对所有错误自动无限重试。

订阅注册、接收门与清理重试的状态图示意

图3:订阅注册、接收门与清理重试的状态图示意。

状态图里最值得保留的是“停止接收但待清理”这一分支。它不是成功运行状态,也不是永久失败终点。用户恢复权限或服务以后,可以再次停止以重试清理;清理完成后才能启动新轮次。界面因此不必添加一个虚构的“强制释放”按钮,应用也没有声称它能越过系统状态强制完成所有退订。

接收回调还应该保持轻量。当前实现只更新计数和一个格式化读数,没有在每次位置到来时重建大列表、发起地址解析或写入文件。将来接入质量门时,先做有限的数值判断,再决定是否进入更重的流程。地址解析属于另一种请求,不能随着位置频率无条件发起,否则订阅本身的资源边界会被下游任务淹没。

五、收尾笔记:按所有权验收

第一次验证只观察生命周期:开始后停留前台,确认计数随回调变化;连续点击开始不应产生第二套监听;点停止后计数应保持,重复停止不应报出应用自身的状态异常。随后重新开始并切到其他应用,回到页面后应处于停止状态,必须再次主动开始。不要以恰好二秒一跳作为验收门槛,因为请求节奏不是硬实时协议。

第二轮验证故障路径:采样时关闭位置开关,检查持续错误是否被呈现并触发收尾;撤销权限后检查停止失败是否有提示,再恢复条件重试停止。若某台设备不支持目标能力,开始应给出注册失败,不能留下看似正在采样的状态。图中的零计数和流程箭头都是代码布局与机制示意,没有宣称任何设备已经通过这组检查。

验收过程中还应区分重复位置和重复订阅。两个连续回调返回相同坐标,并不证明注册了两次;静止设备自然可能产生重复位置。反过来,计数增长很快也不能单凭肉眼推断系统违背间隔参数。先检查本地控制器是否只完成一次注册,再结合回调到达记录判断,避免把产品层计数误当成硬件采样次数。

对于清理异常,示例只能停止接收并保留失败信息,不能保证越过权限或服务故障完成系统退订。页面销毁后仍需在应用级资源诊断中检查这类残留;生产项目可以把失败清理统一交给长期存活的所有者重试。这里明确保留这个边界,不用一句“调用停止即可完全释放”抹掉异常路径,也不把后台权限当成清理失败的通用修复办法。

这份实现的静态检查重点是每个成功注册都有稳定回调和退订路径,尚未在目标设备运行。只有在设备上完成上述前后台与异常操作,才能评价实际资源释放和事件行为。代码至此具备一轮完整的前台采样生命周期;下一篇只接收这些原始样本中的必要字段,讨论哪些点可以参与距离计算,而不是继续扩张订阅控制器的职责。

参考资料

Logo

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

更多推荐