先看结论与判断条件

  • Activity 重建、后台进程被系统终止、应用崩溃和用户强制停止具有不同语义,必须建立独立用例和预期结果。
  • 状态应按瞬时界面、可恢复界面、持久业务、服务端权威和敏感会话分层,不能把整个内存对象图序列化后原样恢复。
  • 恢复入口必须重新执行身份、权限、数据版本和导航前置条件,不能因为旧 back stack 指向某页面就跳过最新校验。
  • 未提交交易和持久工作要使用稳定 operationId、幂等提交和可查询状态,避免进程重建后重复扣费、重复上传或永久悬挂。
  • 退出记录用于确认进程事件、版本、进程和时间窗,但系统退出原因不能单独证明 VMP 是状态丢失或恢复失败的根因。
  • 放行证据必须绑定同一 APK 摘要、测试路径、设备系统、恢复快照和业务断言,单一设备恢复成功不能外推整个矩阵。

先区分四种看起来相似但契约不同的重建场景

旋转屏幕或配置变化通常导致 Activity 等组件重建,进程与部分内存对象仍可能存在;系统在后台回收进程后,任务和必要状态可能被系统用于后续恢复;崩溃代表异常终止;用户强制停止还会改变包的运行状态。把四者混成“重启 App”会得到错误的恢复预期。

加固兼容回归要为每种场景定义触发、观察和退出条件。配置变化检查组件级状态,系统终止检查无内存前提下的任务恢复,崩溃检查未提交状态与诊断材料,强制停止检查明确的冷进入和后台行为。某个场景通过不能替代另一个,因为系统保留的上下文和允许执行的组件不同。

工程判断上,最关键的进程死亡用例是:用户已经完成一部分业务输入,应用进入后台,进程被终止,用户从最近任务或受控入口返回。回归不应依赖调试器保持进程,也不应把测试框架自身存活当作应用存活。本文没有项目候选与设备回执,因此只提供契约和门禁方法。

四类重建场景的测试语义
场景进程状态进入方式主要断言
组件重建进程可能仍在系统重建 Activity可恢复界面状态正确
系统终止后恢复旧进程不存在最近任务或受控入口只依赖持久状态恢复
异常崩溃后重进进程异常结束用户重新进入未提交事务与错误可解释
用户强制停止包进入停止状态用户显式启动按产品定义冷进入
应用更新后重进代码与数据版本变化新版本入口迁移后恢复而非旧对象复活

把状态拆成瞬时、可恢复、持久、权威和敏感五层

瞬时状态包括动画进度、临时对象和正在显示的加载效果,进程死亡后通常无需恢复;可恢复界面状态包括当前步骤、筛选条件和未提交输入,但必须控制大小与敏感度;持久业务状态包括草稿、任务标识和已确认结果;服务端权威状态包括账户权益与交易状态;敏感会话需要重新验证有效性。

恢复契约应逐字段写出 owner、storage、restoreRule 和 invalidationRule。某个值由服务端权威,就不能只恢复本地旧副本;某个值含敏感输入,就不应为了“无缝体验”直接进入长期持久存储;某个值可以重新计算,就不必复制整个内存结构。清晰的所有权比框架自动保存更多对象更可靠。

VMP 可以保护状态处理与业务规则的客户端实现,但不能改变数据生命周期。加固前依赖单例、静态字段或未声明初始化顺序的代码,在进程重建后本就容易失败;保护后只是更难从表面栈判断。回归应明确以空内存启动,并让每个恢复值都能追到持久来源或服务端来源。

恢复状态的五层所有权
状态层示例恢复策略禁止做法
瞬时界面动画、临时加载标记重新计算或重置承诺完全还原帧状态
可恢复界面步骤、筛选、非敏感输入最小字段恢复保存大型对象图
持久业务草稿、operationId、结果版本事务化存储并校验版本依赖静态字段
服务端权威权益、订单和任务最终状态重新查询与合并本地缓存永久覆盖
敏感会话令牌状态和高风险确认验证有效期或重新认证明文长期保存

恢复导航必须重新经过身份、权限和数据版本检查

任务恢复可能把用户带回之前的页面,但页面可见不等于业务条件仍成立。重建后应重新检查会话、账户切换、资源归属、权限、数据版本和必需依赖,再决定恢复原目的地、退回安全入口或显示明确的重新加载状态。旧 back stack 不能成为绕过最新授权的通道。

导航入口要有稳定标识,而不是直接序列化 Fragment、Activity 或 ViewModel 对象。恢复记录可以包含 routeId、resourceId、step 和非敏感参数,进入时由当前版本的路由表解析。若路由已删除、参数模式升级或资源不可用,应走受控 fallback,而不是循环跳转或空白页面。

Android app manifest 定义组件、intent filter、权限、SDK 约束和应用元数据。回归应核对最终候选的 Activity、Service、Receiver、Provider 与 process 声明,特别是外部入口和多进程组件。Manifest 是静态入口清单,不能证明业务代码在重建后没有放宽权限或跳过状态校验。

导航恢复前置条件
检查数据来源失败动作测试断言
会话有效认证服务与本地受限会话重新认证或安全首页不进入受限页面
资源归属服务端或持久业务库显示不可用并退出路径跨账号资源不恢复
路由存在当前版本路由表使用明确 fallback无跳转循环
参数版本恢复记录 schema迁移或丢弃旧参数不反序列化未知对象
组件可达最终 Manifest 与运行状态从受控入口重建进程和权限符合预期

未提交交易和持久工作要用幂等身份恢复

进程可能在“请求已发送但响应未持久化”或“本地已写入但后台工作未确认”之间结束。重建后若只根据界面按钮重新提交,就可能重复产生价值;若默认任务已经完成,又可能丢失结果。解决办法是为业务操作分配稳定 operationId,并在持久存储和服务端记录明确状态。

恢复逻辑先查询 operationId 当前状态,再决定继续等待、查询服务端、重试同一幂等操作或进入人工处理。pending、committed、rejected 和 unknown 需要不同界面与后续动作。unknown 不能直接当作失败后新建操作,也不能为了用户体验自动标记 committed。

本文不展开后台调度器的选择,而聚焦恢复契约。无论工作由哪种机制执行,必须能回答谁创建、输入摘要是什么、是否允许重试、最终结果由谁确认、重建后如何关联。VMP 保护工作编排代码不等于持久化事务已经原子,也不替代服务端幂等控制。

进程重建后的操作状态处理
持久状态重建动作服务端动作禁止结果
draft恢复可编辑草稿不提交高价值操作误认为已发送
pending显示处理中并查询按 operationId 返回状态生成新操作重复提交
committed展示已确认结果返回同一幂等结果再次扣费或执行
rejected展示原因与可重试边界保留拒绝记录客户端改写为成功
unknown阻塞新提交并调查对账或人工确认猜测成功或失败

用退出记录确认真实发生的是哪类进程事件

Android ApplicationExitInfo 可以提供退出原因、进程、时间和可用诊断材料。进程死亡回归应在动作前后设置时间窗,读取目标 processName 的记录,并与 APK 摘要、versionCode 和 testRunId 关联。只有确认旧进程确实结束,后续恢复断言才具备进程重建语义。

退出 reason 仍不能单独证明 VMP 是恢复失败原因。系统资源回收、崩溃、ANR、用户操作和包状态变化需要不同调查路径;记录也可能来自测试前的历史运行或辅助进程。工程上要保存 included 与 excluded 条目,让审阅者看到筛选依据,而不是只展示一条符合预期的记录。

若无法取得退出记录,可以使用独立的进程身份与应用初始化标识确认重建,但报告必须说明证据等级。测试脚本主动终止进程只能证明该触发方式下的恢复,不代表所有系统资源压力场景。最终门禁应同时查看死亡确认、恢复快照和业务断言,避免单点证据。

进程死亡证据的组合
证据回答的问题需要绑定局限
ApplicationExitInfo系统记录了何类退出版本、进程和时间窗分类不等于根因
进程身份恢复前后是否同一进程testRunId 与初始化事件不说明退出原因
状态快照持久数据是否符合契约schema 与候选摘要不证明页面可用
导航断言用户恢复到何处入口和账户状态不证明事务正确
业务回执操作是否重复或丢失operationId 与服务端状态需要服务端配合

平台行为变化要求按系统版本重跑而不是只改 targetSdk

Android 16 all-app behavior changes 说明部分行为变化会影响所有应用,而不只取决于 targetSdk。进程与任务恢复回归因此要覆盖项目支持的新系统,即使构建目标暂未提升。平台变化不意味着所有加固应用都会出现问题,但它足以成为重新运行关键契约的触发条件。

系统矩阵要围绕最低支持版本、主要线上版本、最新平台与关键厂商环境选择,并记录导航模式、多窗口、后台限制和进程配置。测试条件变化时不要复用旧通过结论。相同 APK 在不同系统上的任务与生命周期表现可能不同,报告必须限定结论适用范围。

Android vitals 可以提供崩溃、ANR、启动和设备分布等发布后信号,帮助发现某个系统或设备群的异常方向。样本受安装来源、用户同意和统计口径限制,不能替代发布前的状态恢复回归。恢复错误如果没有崩溃,也可能只表现为错页、重复操作或数据丢失,需要业务遥测和客服回执补充。

进程恢复设备矩阵的选择维度
维度最低覆盖原因报告边界
系统版本最低、主要与最新平台生命周期和行为变化不外推未测版本
ABI实际发布架构Native 加载与桥接差异单架构不能代表全部
进程模式主进程与关键辅助进程状态所有权不同分别记录退出与恢复
入口方式最近任务、图标和受控深链任务栈与参数不同每个入口独立断言
账户状态有效、过期与切换恢复必须重新授权不保存真实用户数据

把恢复契约作为安全发布证据而不是一次手工演示

Android instrumented tests 可以访问真实 Android 运行时、组件和系统 API,适合验证组件重建、持久存储、导航和权限。真正的进程死亡通常需要测试编排在应用进程外分阶段执行:先生成状态与 before 快照,再让进程结束,随后从指定入口重建并生成 after 快照,最后由独立校验器比较。

NIST SP 800-218 SSDF 强调来源、构建、验证、变更与供应链风险记录。恢复门禁应保存候选摘要、测试代码版本、状态 schema、触发方式、退出证据、两份快照和断言结果。代码或保护范围变化后重新运行,而不是复制上次截图或只更新日期。

门禁结果可分 accept、investigate 和 blocked。所有 requiredRunId 完成、进程死亡被确认、持久字段与导航符合契约、没有重复操作时,才进入 accept 候选;证据不足或退出类型不明时 investigate;候选错配、敏感状态越权恢复、事务重复或关键路径失败时 blocked。

  • before 与 after 快照绑定同一候选和 runId
  • 测试编排独立于被终止的应用进程
  • 持久、瞬时、敏感和服务端状态分开断言
  • operationId 重建前后保持一致且不会重复提交
  • 导航恢复重新执行身份和资源校验
  • 退出证据、设备条件和结论一起存档

用只读脚本比较进程死亡前后的状态与导航入口

下面的 Python 示例读取一份脱敏回归记录 JSON。记录包含候选摘要、scenario 事件序列、before 和 after 快照以及 contract;快照只保存测试字段、导航入口、operationId 和进程身份,不包含令牌、用户明文或内部地址。脚本先验证确实出现 background、process-death 与 relaunch 事件,再比较恢复契约。

contract 将字段分为 durableEqual、transientReset、sensitiveAbsent,并列出 allowedNavigation 与 operationIdField。脚本要求持久字段完全一致、瞬时字段回到预期默认值、敏感字段在 after 中不存在、导航属于允许集合、进程身份发生变化且 operationId 不变。任何输入缺失或契约违背都会非零退出。

准备加固后进程恢复评估时,可整理候选 APK、最终 Manifest、状态所有权表、before/after 快照、operationId、退出记录、入口矩阵与设备范围,再通过御盾中央平台提交申请。本站关于 ApplicationExitInfo 异常退出复核的文章可用于确认进程事件,但不能替代恢复业务断言。

  • 记录只包含脱敏测试状态和稳定标识
  • 事件顺序必须证明死亡发生在两次快照之间
  • 持久、瞬时与敏感字段采用不同契约
  • 恢复后 processIdentity 必须变化
  • operationId 保持稳定以支持幂等查询
  • 脚本通过仍需设备和服务端真实回执
校验进程死亡场景及恢复前后状态契约
#!/usr/bin/env python3
import json
import re
import sys
from pathlib import Path

SHA256 = re.compile(r"^[a-f0-9]{64}$")
REQUIRED_EVENTS = ["snapshot-before", "background", "process-death", "relaunch", "snapshot-after"]

def fail(message, code):
    print(message, file=sys.stderr)
    raise SystemExit(code)

def load_record(path):
    try:
        value = json.loads(path.read_text(encoding="utf-8"))
    except (OSError, json.JSONDecodeError) as exc:
        fail("cannot read recovery record: " + str(exc), 3)
    if not isinstance(value, dict):
        fail("recovery record root must be an object", 4)
    return value

def require_text(record, field):
    value = record.get(field)
    if not isinstance(value, str) or not value.strip():
        fail("missing text field: " + field, 5)
    return value.strip()

def require_list(record, field, allow_empty=False):
    value = record.get(field)
    if not isinstance(value, list):
        fail("invalid list field: " + field, 6)
    items = []
    for item in value:
        if not isinstance(item, str) or not item.strip():
            fail("invalid item in: " + field, 7)
        items.append(item.strip())
    if not allow_empty and not items:
        fail("empty required list: " + field, 8)
    if len(items) != len(set(items)):
        fail("duplicate item in: " + field, 9)
    return items

def require_object(record, field):
    value = record.get(field)
    if not isinstance(value, dict):
        fail("invalid object field: " + field, 10)
    return value

def validate_identity(document):
    candidate = require_object(document, "candidate")
    artifact = require_text(candidate, "artifactSha256")
    if not SHA256.fullmatch(artifact):
        fail("invalid artifactSha256", 11)
    version = candidate.get("versionCode")
    if not isinstance(version, int) or isinstance(version, bool):
        fail("invalid versionCode", 12)
    run_id = require_text(document, "testRunId")
    return artifact, version, run_id

def validate_event_order(document):
    scenario = require_object(document, "scenario")
    events = scenario.get("events")
    if not isinstance(events, list):
        fail("scenario events must be a list", 13)
    names = []
    timestamps = []
    for event in events:
        if not isinstance(event, dict):
            fail("scenario event must be an object", 14)
        names.append(require_text(event, "name"))
        timestamp = event.get("timestampMs")
        if not isinstance(timestamp, int) or isinstance(timestamp, bool):
            fail("event timestampMs is invalid", 15)
        timestamps.append(timestamp)
    positions = []
    for required in REQUIRED_EVENTS:
        if required not in names:
            fail("missing scenario event: " + required, 16)
        positions.append(names.index(required))
    if positions != sorted(positions) or timestamps != sorted(timestamps):
        fail("scenario events are out of order", 17)
    return names

def compare_contract(document):
    before = require_object(document, "before")
    after = require_object(document, "after")
    contract = require_object(document, "contract")
    before_state = require_object(before, "state")
    after_state = require_object(after, "state")
    durable = require_list(contract, "durableEqual")
    transient = require_object(contract, "transientReset")
    sensitive = require_list(contract, "sensitiveAbsent", True)
    allowed_navigation = set(require_list(contract, "allowedNavigation"))
    operation_field = require_text(contract, "operationIdField")
    findings = []
    for field in durable:
        if field not in before_state or field not in after_state:
            findings.append({"field": field, "problem": "missing-durable-field"})
        elif before_state[field] != after_state[field]:
            findings.append({"field": field, "problem": "durable-value-changed"})
    for field, expected in transient.items():
        if after_state.get(field) != expected:
            findings.append({"field": field, "problem": "transient-not-reset"})
    for field in sensitive:
        if field in after_state:
            findings.append({"field": field, "problem": "sensitive-state-restored"})
    before_operation = before_state.get(operation_field)
    after_operation = after_state.get(operation_field)
    if not isinstance(before_operation, str) or before_operation != after_operation:
        findings.append({"field": operation_field, "problem": "operation-identity-changed"})
    before_process = require_text(before, "processIdentity")
    after_process = require_text(after, "processIdentity")
    if before_process == after_process:
        findings.append({"field": "processIdentity", "problem": "process-death-not-demonstrated"})
    navigation = require_text(after, "navigationEntry")
    if navigation not in allowed_navigation:
        findings.append({"field": "navigationEntry", "problem": "unexpected-destination"})
    return findings

def main():
    if len(sys.argv) != 2:
        print("Usage: recovery_gate.py RECOVERY_RECORD_JSON", file=sys.stderr)
        raise SystemExit(2)
    record_path = Path(sys.argv[1])
    if not record_path.is_file():
        print("recovery record is missing", file=sys.stderr)
        raise SystemExit(2)
    document = load_record(record_path)
    artifact, version, run_id = validate_identity(document)
    events = validate_event_order(document)
    findings = compare_contract(document)
    result = {
        "status": "blocked" if findings else "contract-passed",
        "artifactSha256": artifact,
        "versionCode": version,
        "testRunId": run_id,
        "events": events,
        "findings": findings,
    }
    print(json.dumps(result, ensure_ascii=False, indent=2, sort_keys=True))
    if findings:
        raise SystemExit(20)

if __name__ == "__main__":
    main()

事实依据与适用边界

以下内容区分官方事实、本文工程判断和不能外推的范围,避免把设计建议写成未经验证的产品结论。

本文判断事实或工程依据适用限制
进程退出信息可以提供原因、进程、时间和部分诊断材料。Android ApplicationExitInfo 描述进程退出记录及可用 trace。退出记录仍需绑定版本、时间窗、用户路径、进程与候选产物。
依赖 Android 运行时、组件和系统 API 的恢复语义应在设备端验证。Android instrumented tests 说明设备测试可访问真实 Android 框架能力。单一设备通过不能代表完整 API、ABI、厂商和入口矩阵。
线上质量观察可提供崩溃、ANR、启动和设备分布等信号。Android vitals 描述 Play 生态中的应用质量指标。样本受渠道、用户同意和统计口径限制,也可能看不到无崩溃的状态错误。
部分 Android 16 行为变化会影响所有应用,不只取决于 targetSdk。Android 16 all-app behavior changes 列出相应平台变化。平台变化不意味着所有加固应用都会触发同一恢复问题。
Manifest 定义组件、权限、intent filter、SDK 约束和应用元数据。Android app manifest 说明 AndroidManifest.xml 的声明职责。静态清单不能证明运行时恢复路径没有被业务代码放宽访问控制。
安全发布应保留来源、构建、验证、变更与供应链风险证据。NIST SP 800-218 SSDF 提供组织级安全软件开发实践框架。该框架不定义 VMP 产品功能,也不证明某个候选已经通过恢复回归。
恢复状态必须按所有权和生命周期分层,不能整体复制旧内存对象图。工程判断:瞬时、持久、服务端权威和敏感状态具有不同恢复与失效规则。具体字段和存储方式需要项目数据模型、安全要求与迁移证据确认。
持久工作在进程重建后要用稳定 operationId 查询并保持幂等。工程判断:请求发送与结果持久化之间可能发生进程终止,重复提交会改变业务结果。客户端标识不能替代服务端原子状态与幂等控制,仍需端到端回执。

工程常见问题

旋转屏幕后界面恢复是否等于进程死亡恢复通过?

不等于。配置变化时进程和部分内存对象可能仍存在,无法证明应用能在空内存前提下从持久状态与服务端状态重建。两者应使用独立用例和断言。

用户强制停止能否完全模拟系统回收后台进程?

不能。强制停止会改变包的运行状态,系统后台终止与任务恢复具有不同语义。可以把它作为单独用例,但不能替代系统进程死亡场景。

恢复 back stack 后显示原页面就算成功吗?

不算。还要重新验证会话、资源归属、参数版本和业务状态,核对未提交事务与 operationId。旧页面可见不能证明权限和数据仍正确。

为什么未提交操作必须保存 operationId?

进程可能在请求已发送但结果未持久化时结束。稳定 operationId 让重建后查询同一操作并获得幂等结果,避免新建请求造成重复扣费、上传或执行。

ApplicationExitInfo 没有记录能否证明进程未死亡?

不能直接证明。还要检查采集能力、系统版本、进程身份和测试时间窗。若退出记录不可用,可用初始化身份辅助确认,但必须降低证据等级并说明限制。

申请进程恢复兼容评估前应准备哪些材料?

准备候选 APK、最终 Manifest、状态所有权表、before/after 快照、operationId、退出记录、入口与设备矩阵,再从御盾中央平台提交申请。

想用自己的 App 验证?

提交候选包、目标系统和关键业务路径,申请御盾 PoC 与兼容性评估。

继续阅读: App 加固 PoC 如何形成发布结论