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

32 阅读11分钟

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 仍可能拿到上一次的参数;而首次冷启动也可能完全没有业务参数。如果简单地写成“有参数就是外部拉起、没参数就是后台恢复”,冷启动和参数复用都会被误判。

因此需要组合判断:

生命周期状态负责回答:元服务刚才是不是在后台?
参数指纹负责回答:这次是否出现了新的业务意图?

四、整体实现

下面给出一个完整的 LaunchSceneTracker。它具备以下能力:

  • 区分冷启动、后台恢复、新拉起意图和重复显示;
  • 对对象 key 排序,避免 { a: 1, b: 2 }{ b: 2, a: 1 } 产生不同指纹;
  • 过滤 undefined、函数等不稳定内容;
  • 使用轻量 FNV-1a 哈希缩短日志和缓存内容;
  • 将上一次有效指纹写入本地缓存;
  • 只在当前进程内保存 HIDDEN 状态,避免进程被杀后把下一次冷启动误判为后台恢复。

4.1 场景追踪器

新建 utils/launch-scene-tracker.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 中接入

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 为什么参数要稳定序列化

下面两个对象的业务含义相同:

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 可能包含框架或系统提供的字段,其中某些字段可能与业务无关,甚至每次值都不同。把它们全部纳入指纹会导致普通后台恢复也被识别成新拉起。

更稳妥的方式是建立业务白名单,只选取真正表示启动意图的字段,例如:

function extractBusinessParams(options) {
  const query = (options && options.query) || {};
  const extraData = (options && options.extraData) || {};

  return {
    orderId: query.orderId || '',
    activityId: extraData.activityId || '',
    requestId: extraData.requestId || ''
  };
}

六、推荐让拉起方增加 requestId

如果可以同步改造拉起方,建议每次拉起都携带唯一请求标识:

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元服务进入后台后返回,仍收到 P1BACKGROUND_RESUME
5元服务在后台,使用参数 P2 拉起NEW_LAUNCH_INTENT
6元服务在前台,使用参数 P2 再次拉起NEW_LAUNCH_INTENT
7参数对象 key 顺序变化,但值相同指纹相同,不重复消费
8onHide 后进程被系统回收,再次打开COLD_START
9其他元服务使用相同参数 P1 再次拉起默认视为恢复;加入唯一 requestId 后可识别为新意图
10其他元服务无参数拉起无法与后台恢复可靠区分,按约定降级处理

调试时建议统一输出这些字段:

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. 所有来源字段只用于业务判断,不替代服务端安全校验。

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