ASCF 元服务如何区分"后台恢复"与"携参拉起":后台标记 + 参数指纹方案
本文适用于使用 ASCF 框架开发的 HarmonyOS 元服务。示例基于应用级
App.onLaunch、App.onShow、App.onHide生命周期,实现一套可直接落地的"后台标记 + 参数指纹"判定方案。说明:这是一种工程启发式方案,不是系统级启动来源鉴权能力。它可以覆盖大多数业务场景,但无法突破"相同参数重复拉起"和"无参数外部拉起"在观测数据上完全一致这一客观限制。
一、问题背景
在 ASCF 元服务中,我们经常需要根据进入方式执行不同逻辑:
- 用户把元服务切到后台后再次返回:恢复页面状态,不重复跳转,不重复请求;
- 另一个元服务携带新参数拉起当前元服务:解析新参数,进入指定业务页面;
- 首次冷启动:执行初始化流程;
- 元服务已经在前台,又收到一次拉起:消费新的业务意图,但不要重复初始化。
难点在于:业务侧目前无法直接依靠一个明确的系统字段,判断这次 onShow 究竟是"从后台恢复",还是"由其他元服务再次拉起"。
华为官方文档说明,ASCF 元服务可以在 App.onLaunch、App.onShow 中获取拉起参数,其中 extraData 只能在这两个应用级回调中获取。因此,我们可以利用两类业务可观测信号进行推断:
- 生命周期状态:上一次是否已经执行过
App.onHide; - 参数变化:本次拉起参数与上一次有效参数是否一致。
核心思路可以概括为一句话:
onHide记录后台状态,onShow对比启动参数指纹;处于后台且参数为空或未变化时,判定为后台恢复;参数发生变化时,判定为新的拉起意图。
官方资料:
二、先明确:我们判断的是"场景",不是可信来源
这套方案不能把拉起方身份作为安全结论,只能判断当前事件更像哪一种运行场景。
假设上一次有效参数指纹为 F1:
| 当前运行状态 | 本次参数 | 指纹关系 | 判定结果 |
|---|---|---|---|
| 进程刚创建 | 任意 | 任意 | 冷启动 |
上一次已 onHide |
空 | 无法生成 | 后台恢复 |
上一次已 onHide |
非空 | 与上次相同 | 后台恢复 |
上一次已 onHide |
非空 | 与上次不同 | 新的拉起意图 |
| 当前已在前台 | 非空 | 与上次不同 | 前台收到新的拉起意图 |
| 当前已在前台 | 空或相同 | 无变化 | 重复 onShow / 无新意图 |
这里有两个必须提前说明的边界:
- 其他元服务如果使用与上次完全相同的参数再次拉起,业务侧无法仅通过参数判断它是一次新拉起;
- 其他元服务如果不传任何参数,其观测结果与普通后台恢复相同,同样无法区分。
如果业务必须做到"一次拉起对应一次处理",拉起方应传递每次都不同的 requestId、traceId 或时间戳。这个唯一标识比接收方单独猜测可靠得多。
三、为什么要同时使用后台标记和参数指纹
只看生命周期不够。
onHide -> onShow 既可能是用户从后台返回,也可能是元服务在后台时被另一个元服务唤起。两种场景都会表现为再次进入 onShow。
只看参数也不够。
部分后台恢复场景中,onShow 仍可能拿到上一次的参数;而首次冷启动也可能完全没有业务参数。如果简单地写成"有参数就是外部拉起、没参数就是后台恢复",冷启动和参数复用都会被误判。
因此需要组合判断:
text
生命周期状态负责回答:元服务刚才是不是在后台?
参数指纹负责回答:这次是否出现了新的业务意图?
四、整体实现
下面给出一个完整的 LaunchSceneTracker。它具备以下能力:
- 区分冷启动、后台恢复、新拉起意图和重复显示;
- 对对象 key 排序,避免
{ a: 1, b: 2 }与{ b: 2, a: 1 }产生不同指纹; - 过滤
undefined、函数等不稳定内容; - 使用轻量 FNV-1a 哈希缩短日志和缓存内容;
- 将上一次有效指纹写入本地缓存;
- 只在当前进程内保存
HIDDEN状态,避免进程被杀后把下一次冷启动误判为后台恢复。
4.1 场景追踪器
新建 utils/launch-scene-tracker.js:
js
const STORAGE_KEY = 'ascf_launch_scene_state_v1';
const RuntimeState = {
STARTING: 'STARTING',
VISIBLE: 'VISIBLE',
HIDDEN: 'HIDDEN'
};
const LaunchScene = {
COLD_START: 'COLD_START',
BACKGROUND_RESUME: 'BACKGROUND_RESUME',
NEW_LAUNCH_INTENT: 'NEW_LAUNCH_INTENT',
DUPLICATE_SHOW: 'DUPLICATE_SHOW'
};
function isPlainObject(value) {
return Object.prototype.toString.call(value) === '[object Object]';
}
function normalize(value) {
if (value === null) {
return null;
}
if (Array.isArray(value)) {
return value.map(function (item) {
return normalize(item);
});
}
if (isPlainObject(value)) {
const result = {};
Object.keys(value).sort().forEach(function (key) {
const item = value[key];
if (item !== undefined && typeof item !== 'function') {
result[key] = normalize(item);
}
});
return result;
}
if (typeof value === 'number' && !Number.isFinite(value)) {
return String(value);
}
return value;
}
function stableStringify(value) {
return JSON.stringify(normalize(value));
}
// 轻量 FNV-1a,仅用于业务去重,不用于安全校验。
function fnv1a(text) {
let hash = 0x811c9dc5;
for (let i = 0; i < text.length; i++) {
hash ^= text.charCodeAt(i);
hash = Math.imul(hash, 0x01000193);
}
return ('00000000' + (hash >>> 0).toString(16)).slice(-8);
}
function isEmptyObject(value) {
return isPlainObject(value) && Object.keys(value).length === 0;
}
function hasValue(value) {
if (value === undefined || value === null || value === '') {
return false;
}
if (Array.isArray(value)) {
return value.length > 0;
}
if (isPlainObject(value)) {
return !isEmptyObject(value);
}
return true;
}
/**
* 只选择能代表"业务意图"的字段。
* 不要直接把 options 中所有系统字段都参与指纹计算,否则系统生成的
* 动态字段可能导致每次 onShow 都产生新指纹。
*/
function extractBusinessParams(options) {
const source = options || {};
const payload = {};
if (hasValue(source.query)) {
payload.query = source.query;
}
if (hasValue(source.extraData)) {
payload.extraData = source.extraData;
}
// 如果不同 path 在你的业务中代表不同启动意图,可以保留这一段。
// 如果后台恢复时 path 会被框架补齐且业务并不关心 path,可以删除。
if (hasValue(source.path)) {
payload.path = source.path;
}
return payload;
}
function createFingerprint(options) {
const payload = extractBusinessParams(options);
if (Object.keys(payload).length === 0) {
return '';
}
return fnv1a(stableStringify(payload));
}
function readPersistedState() {
try {
return has.getStorageSync(STORAGE_KEY) || {};
} catch (error) {
console.warn('[LaunchScene] read cache failed', error);
return {};
}
}
function writePersistedState(state) {
try {
has.setStorageSync(STORAGE_KEY, state);
} catch (error) {
console.warn('[LaunchScene] write cache failed', error);
}
}
class LaunchSceneTracker {
constructor() {
const cache = readPersistedState();
// 运行态不从缓存恢复。新进程一定从 STARTING 开始,避免旧的后台标记污染冷启动。
this.runtimeState = RuntimeState.STARTING;
this.lastFingerprint = cache.lastFingerprint || '';
this.launchFingerprint = '';
}
onLaunch(options) {
const fingerprint = createFingerprint(options);
this.launchFingerprint = fingerprint;
if (fingerprint) {
this.lastFingerprint = fingerprint;
this.persist();
}
}
onShow(options) {
const fingerprint = createFingerprint(options);
let scene;
if (this.runtimeState === RuntimeState.STARTING) {
scene = LaunchScene.COLD_START;
} else if (this.runtimeState === RuntimeState.HIDDEN) {
if (!fingerprint || fingerprint === this.lastFingerprint) {
scene = LaunchScene.BACKGROUND_RESUME;
} else {
scene = LaunchScene.NEW_LAUNCH_INTENT;
}
} else if (fingerprint && fingerprint !== this.lastFingerprint) {
scene = LaunchScene.NEW_LAUNCH_INTENT;
} else {
scene = LaunchScene.DUPLICATE_SHOW;
}
this.runtimeState = RuntimeState.VISIBLE;
// 空参数不能覆盖上一次有效指纹,否则下一次无法完成有效对比。
if (fingerprint) {
this.lastFingerprint = fingerprint;
this.persist();
}
return {
scene: scene,
fingerprint: fingerprint,
params: extractBusinessParams(options)
};
}
onHide() {
this.runtimeState = RuntimeState.HIDDEN;
this.persist();
}
persist() {
writePersistedState({
lastFingerprint: this.lastFingerprint,
updatedAt: Date.now()
});
}
}
module.exports = {
LaunchScene: LaunchScene,
LaunchSceneTracker: LaunchSceneTracker,
createFingerprint: createFingerprint,
extractBusinessParams: extractBusinessParams
};
4.2 在 app.js 中接入
js
const trackerModule = require('./utils/launch-scene-tracker');
const LaunchScene = trackerModule.LaunchScene;
const launchSceneTracker = new trackerModule.LaunchSceneTracker();
App({
globalData: {
latestLaunchResult: null
},
onLaunch(options) {
launchSceneTracker.onLaunch(options);
},
onShow(options) {
const result = launchSceneTracker.onShow(options);
this.globalData.latestLaunchResult = result;
console.info('[LaunchScene]', JSON.stringify(result));
switch (result.scene) {
case LaunchScene.COLD_START:
this.handleColdStart(result.params);
break;
case LaunchScene.BACKGROUND_RESUME:
this.handleBackgroundResume();
break;
case LaunchScene.NEW_LAUNCH_INTENT:
this.handleNewLaunchIntent(result.params);
break;
case LaunchScene.DUPLICATE_SHOW:
default:
// 没有新的业务意图,不重复执行跳转和请求。
break;
}
},
onHide() {
launchSceneTracker.onHide();
},
handleColdStart(params) {
console.info('[LaunchScene] cold start');
this.consumeLaunchParams(params);
},
handleBackgroundResume() {
console.info('[LaunchScene] background resume');
// 按需刷新过期数据或恢复定时器,不重复消费启动参数。
},
handleNewLaunchIntent(params) {
console.info('[LaunchScene] new launch intent');
this.consumeLaunchParams(params);
},
consumeLaunchParams(params) {
const query = params.query || {};
const extraData = params.extraData || {};
// 示例:根据业务参数执行跳转或刷新。
if (query.orderId) {
has.navigateTo({
url: '/pages/order/detail?orderId=' + encodeURIComponent(query.orderId)
});
return;
}
if (extraData.activityId) {
has.navigateTo({
url: '/pages/activity/detail?activityId=' +
encodeURIComponent(extraData.activityId)
});
}
}
});
注意:应接入应用级 App.onShow/App.onHide,不要用某个页面的 Page.onShow/Page.onHide 代替。页面间跳转也会触发页面生命周期,使用页面级回调会把正常路由切换误判成前后台变化。
五、关键设计细节
5.1 为什么参数要稳定序列化
下面两个对象的业务含义相同:
js
const a = { orderId: '1001', source: 'serviceA' };
const b = { source: 'serviceA', orderId: '1001' };
但直接 JSON.stringify 后,字符串可能因 key 的插入顺序不同而不同。稳定序列化会递归排序对象 key,再计算指纹,从而减少误判。
数组没有排序,因为数组顺序通常具有业务含义。例如商品 ID 列表 [1, 2] 和 [2, 1] 是否等价,应由业务自行决定。
5.2 为什么空参数不能覆盖上一次有效指纹
假设其他元服务用参数 P1 拉起当前元服务,之后用户进入后台,再直接返回。后台恢复时本次参数可能为空。
如果用空值覆盖 P1,下一次再收到 P1 时,它会被错误判断为一个全新的参数。因此代码只更新"有效非空指纹"。
5.3 为什么后台标记只保存在内存
本地缓存的生命周期长于进程生命周期。如果把 HIDDEN 持久化,可能出现以下情况:
- 元服务执行
onHide,缓存写入HIDDEN; - 系统随后回收进程,没有机会清理标记;
- 用户再次打开元服务,发生真正的冷启动;
- 旧的
HIDDEN被恢复,冷启动被误判成后台恢复。
因此:
runtimeState只存在当前 JS 进程内;lastFingerprint可以持久化,用于业务去重和诊断;- 每次新进程创建都从
STARTING开始,以onLaunch作为冷启动依据。
5.4 为什么不建议把整个 options 直接计算指纹
options 可能包含框架或系统提供的字段,其中某些字段可能与业务无关,甚至每次值都不同。把它们全部纳入指纹会导致普通后台恢复也被识别成新拉起。
更稳妥的方式是建立业务白名单,只选取真正表示启动意图的字段,例如:
js
function extractBusinessParams(options) {
const query = (options && options.query) || {};
const extraData = (options && options.extraData) || {};
return {
orderId: query.orderId || '',
activityId: extraData.activityId || '',
requestId: extraData.requestId || ''
};
}
六、推荐让拉起方增加 requestId
如果可以同步改造拉起方,建议每次拉起都携带唯一请求标识:
js
const extraData = {
requestId: 'serviceA-' + Date.now() + '-' + Math.random().toString(16).slice(2),
activityId: 'A20260909',
source: 'serviceA'
};
接收方把 requestId 纳入指纹后,即使业务参数完全相同,也能识别为两次独立拉起。
如果还需要确认来源,可以约定 sourceServiceId,但它只能用于业务路由和日志分析,不能作为安全鉴权依据。客户端传入字段可以被伪造,涉及权限、支付或敏感数据时,仍应由服务端校验令牌、签名或业务凭证。
七、测试用例
建议至少覆盖以下场景:
| 编号 | 操作 | 期望结果 |
|---|---|---|
| 1 | 清理进程,无参数打开元服务 | COLD_START |
| 2 | 清理进程,携带参数 P1 打开 |
COLD_START,消费 P1 |
| 3 | 元服务进入后台后直接返回,参数为空 | BACKGROUND_RESUME |
| 4 | 元服务进入后台后返回,仍收到 P1 |
BACKGROUND_RESUME |
| 5 | 元服务在后台,使用参数 P2 拉起 |
NEW_LAUNCH_INTENT |
| 6 | 元服务在前台,使用参数 P2 再次拉起 |
NEW_LAUNCH_INTENT |
| 7 | 参数对象 key 顺序变化,但值相同 | 指纹相同,不重复消费 |
| 8 | onHide 后进程被系统回收,再次打开 |
COLD_START |
| 9 | 其他元服务使用相同参数 P1 再次拉起 |
默认视为恢复;加入唯一 requestId 后可识别为新意图 |
| 10 | 其他元服务无参数拉起 | 无法与后台恢复可靠区分,按约定降级处理 |
调试时建议统一输出这些字段:
js
console.info('[LaunchScene]', JSON.stringify({
scene: result.scene,
fingerprint: result.fingerprint,
params: result.params,
time: Date.now()
}));
日志中不要打印手机号、Token、身份证号等敏感信息。生产环境可以只记录指纹、场景和必要的链路 ID。
八、方案局限与降级策略
局限一:相同参数重复拉起
接收方看到的生命周期和参数与后台恢复完全一致,不存在足够信息完成区分。
解决方式:拉起方增加唯一 requestId。
局限二:无参数外部拉起
无参数外部拉起与无参数后台恢复同样不可区分。
解决方式:约定外部拉起必须携带来源和请求 ID;无法改造时统一降级为后台恢复,不执行破坏性操作。
局限三:参数包含动态系统字段
如果动态字段参与指纹,每次恢复都可能被判断为新意图。
解决方式:用白名单提取业务字段,不对整个 options 直接计算指纹。
局限四:哈希碰撞
FNV-1a 适合日志缩短和一般业务去重,但不是密码学哈希,理论上存在碰撞。
解决方式:对准确性要求高时,直接保存稳定序列化字符串,或使用平台可用的 SHA-256 能力。不要使用该指纹进行安全鉴权。
九、总结
当 ASCF 没有直接暴露"本次 onShow 的明确启动来源"时,可以通过"运行态 + 参数变化"建立一套可解释、可测试的工程判定:
App.onLaunch建立当前进程的冷启动基线;App.onHide在内存中标记元服务已进入后台;App.onShow提取业务参数并生成稳定指纹;- 后台状态下,参数为空或指纹相同,按后台恢复处理;
- 指纹发生变化,按新的拉起意图处理;
- 拉起方提供唯一
requestId,解决相同参数重复拉起的不可区分问题; - 所有来源字段只用于业务判断,不替代服务端安全校验。
这套方案的价值不在于"猜中所有场景",而在于把判断依据、状态变化、误判边界和降级策略全部显式化。这样既能解决大多数实际问题,也能在平台能力升级后平滑替换底层判定逻辑。