先看结论与判断条件
- 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、退出记录、入口与设备矩阵,再从御盾中央平台提交申请。