ASCF 元服务如何区分“后台恢复”与“携参拉起”:后台标记 + 参数指纹方案

ASCF 元服务如何区分"后台恢复"与"携参拉起":后台标记 + 参数指纹方案

本文适用于使用 ASCF 框架开发的 HarmonyOS 元服务。示例基于应用级 App.onLaunchApp.onShowApp.onHide 生命周期,实现一套可直接落地的"后台标记 + 参数指纹"判定方案。

说明:这是一种工程启发式方案,不是系统级启动来源鉴权能力。它可以覆盖大多数业务场景,但无法突破"相同参数重复拉起"和"无参数外部拉起"在观测数据上完全一致这一客观限制。

一、问题背景

在 ASCF 元服务中,我们经常需要根据进入方式执行不同逻辑:

  • 用户把元服务切到后台后再次返回:恢复页面状态,不重复跳转,不重复请求;
  • 另一个元服务携带新参数拉起当前元服务:解析新参数,进入指定业务页面;
  • 首次冷启动:执行初始化流程;
  • 元服务已经在前台,又收到一次拉起:消费新的业务意图,但不要重复初始化。

难点在于:业务侧目前无法直接依靠一个明确的系统字段,判断这次 onShow 究竟是"从后台恢复",还是"由其他元服务再次拉起"。

华为官方文档说明,ASCF 元服务可以在 App.onLaunchApp.onShow 中获取拉起参数,其中 extraData 只能在这两个应用级回调中获取。因此,我们可以利用两类业务可观测信号进行推断:

  1. 生命周期状态:上一次是否已经执行过 App.onHide
  2. 参数变化:本次拉起参数与上一次有效参数是否一致。

核心思路可以概括为一句话:

onHide 记录后台状态,onShow 对比启动参数指纹;处于后台且参数为空或未变化时,判定为后台恢复;参数发生变化时,判定为新的拉起意图。

官方资料:

二、先明确:我们判断的是"场景",不是可信来源

这套方案不能把拉起方身份作为安全结论,只能判断当前事件更像哪一种运行场景。

假设上一次有效参数指纹为 F1

当前运行状态 本次参数 指纹关系 判定结果
进程刚创建 任意 任意 冷启动
上一次已 onHide 无法生成 后台恢复
上一次已 onHide 非空 与上次相同 后台恢复
上一次已 onHide 非空 与上次不同 新的拉起意图
当前已在前台 非空 与上次不同 前台收到新的拉起意图
当前已在前台 空或相同 无变化 重复 onShow / 无新意图

这里有两个必须提前说明的边界:

  1. 其他元服务如果使用与上次完全相同的参数再次拉起,业务侧无法仅通过参数判断它是一次新拉起;
  2. 其他元服务如果不传任何参数,其观测结果与普通后台恢复相同,同样无法区分。

如果业务必须做到"一次拉起对应一次处理",拉起方应传递每次都不同的 requestIdtraceId 或时间戳。这个唯一标识比接收方单独猜测可靠得多。

三、为什么要同时使用后台标记和参数指纹

只看生命周期不够。

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 持久化,可能出现以下情况:

  1. 元服务执行 onHide,缓存写入 HIDDEN
  2. 系统随后回收进程,没有机会清理标记;
  3. 用户再次打开元服务,发生真正的冷启动;
  4. 旧的 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 的明确启动来源"时,可以通过"运行态 + 参数变化"建立一套可解释、可测试的工程判定:

  1. App.onLaunch 建立当前进程的冷启动基线;
  2. App.onHide 在内存中标记元服务已进入后台;
  3. App.onShow 提取业务参数并生成稳定指纹;
  4. 后台状态下,参数为空或指纹相同,按后台恢复处理;
  5. 指纹发生变化,按新的拉起意图处理;
  6. 拉起方提供唯一 requestId,解决相同参数重复拉起的不可区分问题;
  7. 所有来源字段只用于业务判断,不替代服务端安全校验。

这套方案的价值不在于"猜中所有场景",而在于把判断依据、状态变化、误判边界和降级策略全部显式化。这样既能解决大多数实际问题,也能在平台能力升级后平滑替换底层判定逻辑。


相关推荐
何何____1 小时前
js常见继承方法详解
前端·javascript
parade岁月1 小时前
Tailwind CSS 加入 Shopify,正在用 Tailwind 的项目要不要调整?
前端
梨想橙汁1 小时前
Vue2 快速入门:环境搭建、模板语法、指令系统全解
前端·vue.js
King of fraud1 小时前
HTML 页面的 CSS 选择器详解
前端·css·html
前端 贾公子1 小时前
Milvus使用指南 (下)
java·服务器·前端
用户2462035827811 小时前
TypeScript类型建模与前后端错误码协议:手机号绑定场景的工程实践
前端
风骏时光牛马1 小时前
云原生架构设计:弹性底座驱动业务持续迭代
前端
用户54277848515401 小时前
浏览器后台休眠节流
前端
蜡台1 小时前
Vue 3 原生 ESM 开发实战:不用打包工具,从零搭建组件化应用
前端·javascript·vue.js·html