HarmonyOS 坐标采样(第3篇):连续订阅的前台寿命【共创稿事节】
这是一份连续采样功能的实现笔记,按设计迭代记录每轮应该检查的内容,不把未执行的设备操作写成开发经历。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:订阅注册、接收门与清理重试的状态图示意。
状态图里最值得保留的是“停止接收但待清理”这一分支。它不是成功运行状态,也不是永久失败终点。用户恢复权限或服务以后,可以再次停止以重试清理;清理完成后才能启动新轮次。界面因此不必添加一个虚构的“强制释放”按钮,应用也没有声称它能越过系统状态强制完成所有退订。
接收回调还应该保持轻量。当前实现只更新计数和一个格式化读数,没有在每次位置到来时重建大列表、发起地址解析或写入文件。将来接入质量门时,先做有限的数值判断,再决定是否进入更重的流程。地址解析属于另一种请求,不能随着位置频率无条件发起,否则订阅本身的资源边界会被下游任务淹没。
五、收尾笔记:按所有权验收
第一次验证只观察生命周期:开始后停留前台,确认计数随回调变化;连续点击开始不应产生第二套监听;点停止后计数应保持,重复停止不应报出应用自身的状态异常。随后重新开始并切到其他应用,回到页面后应处于停止状态,必须再次主动开始。不要以恰好二秒一跳作为验收门槛,因为请求节奏不是硬实时协议。
第二轮验证故障路径:采样时关闭位置开关,检查持续错误是否被呈现并触发收尾;撤销权限后检查停止失败是否有提示,再恢复条件重试停止。若某台设备不支持目标能力,开始应给出注册失败,不能留下看似正在采样的状态。图中的零计数和流程箭头都是代码布局与机制示意,没有宣称任何设备已经通过这组检查。
验收过程中还应区分重复位置和重复订阅。两个连续回调返回相同坐标,并不证明注册了两次;静止设备自然可能产生重复位置。反过来,计数增长很快也不能单凭肉眼推断系统违背间隔参数。先检查本地控制器是否只完成一次注册,再结合回调到达记录判断,避免把产品层计数误当成硬件采样次数。
对于清理异常,示例只能停止接收并保留失败信息,不能保证越过权限或服务故障完成系统退订。页面销毁后仍需在应用级资源诊断中检查这类残留;生产项目可以把失败清理统一交给长期存活的所有者重试。这里明确保留这个边界,不用一句“调用停止即可完全释放”抹掉异常路径,也不把后台权限当成清理失败的通用修复办法。
这份实现的静态检查重点是每个成功注册都有稳定回调和退订路径,尚未在目标设备运行。只有在设备上完成上述前后台与异常操作,才能评价实际资源释放和事件行为。代码至此具备一轮完整的前台采样生命周期;下一篇只接收这些原始样本中的必要字段,讨论哪些点可以参与距离计算,而不是继续扩张订阅控制器的职责。
参考资料
更多推荐




所有评论(0)