先看结论与判断条件

  • ApplicationExitInfo 提供的是进程退出分类和可用诊断材料,不能脱离候选身份、时间窗和用户路径直接充当根因结论。
  • 每条退出记录至少绑定 APK 摘要、versionCode、processName、测试运行标识、动作路径和记录时间,避免把历史退出错配给当前候选。
  • Java 或 Kotlin 崩溃、Native signal、ANR、低内存和系统操作需要不同材料与复测方式,不能只统计一个异常退出总数。
  • ANR 应继续检查主线程阻塞、锁竞争、Binder、I/O 与组件超时,表象堆栈中的受保护方法不一定是最初阻塞源。
  • 混淆堆栈必须配对同一构建的 R8 mapping;Native tombstone 需要对应符号和 Build ID,retrace 不能处理 Native 符号。
  • 回归门禁应输出接受、继续调查或阻塞,并明确设备与线上样本的覆盖边界,不能用单机未复现替代证据。

先把退出原因当成调查入口而不是根因

Android ApplicationExitInfo 能描述进程为何结束,并在相应场景提供 ANR trace 或 Native tombstone 等材料。它解决的是“系统记录了哪类退出、发生在何时、属于哪个进程”,不是“哪一行受保护代码导致退出”。同一个 reason 可能对应多种业务路径、线程关系和系统条件,必须继续取证。

加固版本出现异常退出时,最容易犯的错误是看到 crash、ANR 或 signal 就直接归因 VMP,也可能因为记录显示 low memory 或 user requested 就完全排除代码问题。系统分类只能选择下一步材料:Java 堆栈走 mapping,Native 信号走符号与 tombstone,ANR 走线程和组件超时分析,资源退出则复核内存、后台限制和测试条件。

工程判断上,退出门禁要同时防止两种误判:把所有新增异常都算给加固,以及把单次未复现写成兼容通过。本文没有具体候选和设备回执,只提供记录绑定、分流、复测与门禁方法,不声称任何 App、系统版本或 VMP 配置已经通过。

退出原因与调查责任的关系
退出分类优先材料首轮问题不能直接得出
Java 或 Kotlin crash异常堆栈与同构建 mapping异常类型和业务路径是什么VMP 已被证明是根因
Native signaltombstone、符号和 Build ID崩溃线程与信号上下文是什么retrace 可以完成符号化
ANRANR trace 与所有线程主线程在等什么表象栈就是阻塞源
资源或系统退出内存、系统状态和测试条件谁触发了终止应用代码完全无关
用户或包状态操作操作记录和时间窗是否属于预期测试动作当前候选没有其他异常

把每条记录绑定到候选、版本、进程和时间窗

Android ApplicationExitInfo 的记录可能覆盖多个历史进程和时间点,多进程应用还会产生不同 processName。回归复核应从实际安装候选开始保存 APK 摘要、versionCode、versionName、签名身份、构建变体和测试运行标识,再在动作前后记录时间边界。缺少候选摘要时,相同版本号也不能保证是同一产物。

测试动作需要可关联。冷启动、登录、下载、后台任务、进程重建或组件调用都应有 runId 和路径名称,记录开始、结束与预期进程。读取退出历史后,只保留落在时间窗且属于目标进程的条目,其他记录进入旁路清单。这样可以避免把测试前的旧 ANR 或辅助进程退出归到当前动作。

时间窗不能无限放宽。过窄会漏掉延迟退出,过宽会混入系统清理和其他手工操作。工程上可按业务路径定义观察结束条件,例如结果页面出现、后台任务完成或进程稳定,再额外保留一个明确的后观察阶段。具体时长必须由项目运行特征决定,不能把任意固定数字当成通用标准。

退出记录最小身份字段
字段来源用途缺失风险
artifactSha256被测 APK 文件绑定唯一候选同版本不同包错配
versionCodePackageInfo 或构建记录筛选应用版本历史记录混入
processName退出记录与测试计划区分主进程和辅助进程错误归因业务路径
testRunId测试编排连接动作和证据无法复现实验
windowStart/windowEnd测试时钟筛选相关退出漏记或误收历史事件

按退出分类生成不同的回归任务而不是统一重跑

门禁的价值在于把系统分类转成下一步动作。Java crash 需要保存原始堆栈、异常类型和 mapping 身份;Native signal 需要 tombstone、库清单、ABI、Build ID 和符号材料;ANR 需要主线程及相关线程、组件类型和操作时间线;资源退出需要内存与系统压力条件。不同任务使用同一候选,但证据入口不同。

同一 reason 还要按进程和业务路径拆分。辅助推送进程的退出不等于主流程失败,主进程在冷启动退出也不能用后台任务成功抵消。回归表应为 reason、processName、pathName 建立独立行,列出样本数、可用 trace、符号状态、复现状态和下一步责任,避免总数掩盖集中问题。

退出记录不存在也不是自动通过。测试可能没有覆盖目标路径,系统版本可能不提供同样材料,读取权限或采集代码也可能失败。门禁必须区分 no-exit-observed、collection-unavailable 和 path-not-executed;只有目标动作确实完成、采集成功且观察窗内没有非预期退出,才有资格进入进一步的兼容判断。

退出分类到回归任务的映射
分类必收材料复测重点阻塞条件
Java/Kotlin crash原始栈、mapping 摘要相同输入与异常分支无法符号化当前构建
Native signaltombstone、ABI、Build ID库加载与崩溃线程符号或库身份错配
ANR线程 trace、组件和时间线锁、Binder、I/O 与超时主线程证据缺失
资源退出内存、系统压力和后台状态同条件资源轨迹环境无法复现或记录
预期操作退出测试动作和操作者记录确认退出符合预期与意外退出时间重叠

ANR 要沿主线程、锁、Binder、I/O 和组件继续下钻

Diagnose Android ANRs 建议按主线程阻塞、锁竞争、Binder、I/O 与组件超时等方向定位。退出记录若分类为 ANR,第一步是取得 trace 并识别组件类型、主线程状态和等待对象,再检查其他线程是否持锁、是否执行磁盘或网络、远端 Binder 是否迟滞。不能只截取主线程顶部几帧。

受保护方法出现在主线程栈中并不自动代表它制造了阻塞。方法可能等待另一个持锁线程,也可能只是调用链的外层;真正耗时发生在 Binder、文件系统或类加载。反过来,受保护桥接也可能改变执行时间或异常行为。严谨做法是保存完整线程关系和时间线,用相同候选、输入与设备条件复测。

ANR 回归应覆盖正向路径和故障路径。依赖服务慢、磁盘繁忙、锁竞争、进程恢复和组件重复创建都可能暴露问题。每个用例要断言业务是否完成、主线程是否可响应、超时由哪一层处理以及是否生成可诊断错误。没有 trace 或只有单次人工观察时,门禁应保持继续调查。

ANR 分层分析
层次观察对象需要关联常见误判
主线程状态、栈和等待点业务动作和组件顶部帧就是根因
锁竞争持锁线程和临界区所有相关线程只分析等待线程
Binder调用方向和远端延迟服务进程与超时本地栈等于本地耗时
I/O磁盘、网络和类加载缓存与系统状态复现一次即可归因
组件超时Receiver、Service 等生命周期组件类型和系统限制所有 ANR 使用同一门槛

启动阶段退出要同时记录 TTID、TTFD 和动作完成状态

Android app startup time 区分 TTID 与 TTFD,并要求理解冷、温、热启动等条件。加固候选在启动阶段退出时,需要确认进程是否在首帧前终止、首帧出现后是否完成关键初始化、应用是否报告完全可用。只看“出现启动页”会漏掉首帧后的立即崩溃或后台初始化失败。

启动回归应固定候选、设备、启动类型、数据状态和前置进程状态,保存 Application、ContentProvider、首屏与延迟初始化的阶段事件。若退出发生在 TTID 前,优先检查早期组件和类加载;若 TTID 已完成但 TTFD 未完成,检查延迟任务、服务连接和业务就绪信号。测量用于定位阶段,不单独证明因果。

单次启动耗时不能代表分位数,也不能证明 VMP 导致或没有导致变化。缓存、编译状态、后台负载与系统更新都会影响样本。异常退出门禁的首要结论是路径是否完成以及是否出现非预期进程终止;性能趋势需要独立的重复测量与项目阈值,不能和退出分类混成一个结果。

启动退出的阶段化证据
阶段关键事件异常退出说明补充材料
进程创建进程和早期组件开始可能涉及加载或早期初始化组件时间线与原始栈
TTID 前首帧尚未显示用户无法进入界面冷启动条件与 trace
TTID 后首帧已显示仍可能未完全可用延迟任务与业务状态
TTFD 前完整可用尚未报告初始化或数据链未完成就绪信号与服务日志
稳定观察目标动作完成且进程持续才具备无退出样本意义观察窗和系统状态

Java 混淆栈和 Native tombstone 使用不同符号链

R8 retrace 用于把混淆后的 Java 或 Kotlin 崩溃堆栈与 mapping 文件配对还原。加固候选必须保存同一次构建的 mapping 摘要,并在报告中记录 retrace 工具版本、输入栈摘要和输出摘要。使用旧版本 mapping 可能得到看似可读却错误的方法身份,破坏根因分析。

Native signal 的符号化不由 retrace 完成。Android ApplicationExitInfo 在相应版本与条件下可以提供 Native tombstone,项目仍需对应 ABI、库摘要、Build ID 和符号文件。若受保护逻辑跨越 Java 与 Native 桥接,两边材料都要绑定同一候选,不能用 Java mapping 解释 Native 地址。

符号化结果是定位线索,不是自动根因。还要检查崩溃线程、业务输入、调用者、异常或信号上下文,以及同一位置在未加固基线和候选中的表现。若符号材料缺失,可以标记 unknown-symbol 并阻塞相关结论,不能根据方法名猜测或把未解析地址写成 VMP 故障。

两类符号化材料
材料处理对象必须绑定主要限制
R8 mappingJava/Kotlin 混淆栈同构建变体与候选不能处理 Native 地址
原始 Java 栈异常类型和混淆帧退出记录与 testRunId截断会丢失调用关系
Native tombstone信号、线程和地址进程、ABI 与时间窗可用性依系统条件
Native 符号共享库地址还原库摘要与 Build ID错版本产生错误符号
符号化报告可读位置与上下文工具和输入摘要不等于最终根因

设备回归与 Android vitals 分别回答发布前和发布后问题

Android instrumented tests 可以访问真实 Android 运行时、组件和系统 API,适合执行冷启动、进程重建、组件调用、权限拒绝和关键业务路径。每个测试运行都应先确认安装候选摘要,再在动作后读取退出记录,并把 no-exit-observed 与业务断言同时保存。仅有测试进程成功不足以证明应用路径完成。

Android vitals 提供崩溃、ANR、启动和设备分布等线上质量信号,可用于发布后观察某个版本是否在特定设备或路径出现变化。它受安装来源、用户同意、样本量和统计口径限制,不能替代发布前设备回归,也不能为没有覆盖的分发渠道提供结论。

回归门禁可以输出 accept、investigate 或 blocked。目标矩阵完成、业务断言通过、采集可用且没有非预期退出时,才可进入 accept 候选;有分类但材料未齐时进入 investigate;候选身份错配、关键路径退出、ANR 证据缺失或符号无法关联时应 blocked。最终阈值由项目风险与设备范围确定。

  • 每个运行先核对安装候选 APK 摘要
  • 业务断言和退出记录共享同一 testRunId
  • 主进程与辅助进程分别统计
  • 采集不可用不写成没有退出
  • 设备矩阵和线上样本边界独立记录
  • accept、investigate 与 blocked 条件可复核

用只读脚本生成按候选、进程和原因分组的回归表

下面的 Python 示例读取脱敏退出记录 JSON Lines 和一份测试计划 JSON。退出记录包含 artifactSha256、versionCode、processName、reason、timestampMs、testRunId、pathName、traceKind 与 mappingSha256;测试计划定义目标候选、时间窗、允许进程和必须执行的运行标识。脚本拒绝身份错配与未知字段。

脚本先确认记录落在计划时间窗并属于目标候选,再按 processName、pathName 和 reason 分组,输出数量、trace 类型、运行标识与是否需要调查。计划中未出现的 testRunId 会标记 missing-run,窗口外或其他候选记录进入 excluded,而不会被计入当前门禁。它不会读取用户数据、抓取设备或判断真实根因。

准备加固版本异常退出复核时,可整理候选 APK、退出记录、R8 mapping、Native 符号、测试时间窗、动作计划、ANR trace 和设备范围,再通过御盾中央平台提交申请。本站关于 VMP 范围变化影响分析的技术文章可帮助选择需要重跑的契约,但退出原因仍需独立证据归因。

  • 输入记录必须经过脱敏并绑定候选摘要
  • 其他候选或窗口外记录只能进入 excluded
  • 按进程、路径和原因分别分组
  • 缺失 requiredRunId 不能写成无退出
  • reason 只选择调查任务而不自动归因
  • 最终门禁仍结合符号、设备和业务证据
解析脱敏退出记录并生成回归门禁表
#!/usr/bin/env python3
import json
import re
import sys
from collections import defaultdict
from pathlib import Path

SHA256 = re.compile(r"^[a-f0-9]{64}$")
ALLOWED_REASONS = {
    "crash", "native-signal", "anr", "low-memory",
    "resource-pressure", "user-requested", "system-action", "other"
}
ALLOWED_TRACES = {"none", "java-stack", "anr-trace", "native-tombstone"}

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

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

def require_sha(value, field):
    if not isinstance(value, str) or not SHA256.fullmatch(value):
        fail("invalid SHA-256 field: " + field, 5)
    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, 6)
    return value.strip()

def require_int(record, field):
    value = record.get(field)
    if not isinstance(value, int) or isinstance(value, bool):
        fail("invalid integer field: " + field, 7)
    return value

def load_records(path):
    records = []
    try:
        lines = path.read_text(encoding="utf-8").splitlines()
    except OSError as exc:
        fail("cannot read exit records: " + str(exc), 8)
    for line_number, line in enumerate(lines, 1):
        if not line.strip():
            continue
        try:
            record = json.loads(line)
        except json.JSONDecodeError as exc:
            fail("invalid JSON line " + str(line_number) + ": " + str(exc), 9)
        if not isinstance(record, dict):
            fail("record must be an object", 10)
        reason = require_text(record, "reason")
        trace_kind = require_text(record, "traceKind")
        if reason not in ALLOWED_REASONS:
            fail("unsupported reason: " + reason, 11)
        if trace_kind not in ALLOWED_TRACES:
            fail("unsupported traceKind: " + trace_kind, 12)
        mapping = record.get("mappingSha256")
        if mapping is not None:
            require_sha(mapping, "mappingSha256")
        records.append({
            "artifactSha256": require_sha(record.get("artifactSha256"), "artifactSha256"),
            "versionCode": require_int(record, "versionCode"),
            "processName": require_text(record, "processName"),
            "reason": reason,
            "timestampMs": require_int(record, "timestampMs"),
            "testRunId": require_text(record, "testRunId"),
            "pathName": require_text(record, "pathName"),
            "traceKind": trace_kind,
            "mappingSha256": mapping,
        })
    return records

def validate_plan(plan):
    artifact = require_sha(plan.get("artifactSha256"), "artifactSha256")
    version = require_int(plan, "versionCode")
    start = require_int(plan, "windowStartMs")
    end = require_int(plan, "windowEndMs")
    if start >= end:
        fail("invalid observation window", 13)
    processes = plan.get("processNames")
    runs = plan.get("requiredRunIds")
    if not isinstance(processes, list) or not processes:
        fail("processNames are missing", 14)
    if not isinstance(runs, list) or not runs:
        fail("requiredRunIds are missing", 15)
    if not all(isinstance(x, str) and x.strip() for x in processes + runs):
        fail("plan contains an invalid identifier", 16)
    return artifact, version, start, end, set(processes), set(runs)

def build_report(records, plan):
    artifact, version, start, end, processes, required_runs = validate_plan(plan)
    grouped = defaultdict(lambda: {"count": 0, "traceKinds": set(), "runIds": set()})
    observed_runs = set()
    excluded = 0
    for record in records:
        matches = (
            record["artifactSha256"] == artifact
            and record["versionCode"] == version
            and record["processName"] in processes
            and start <= record["timestampMs"] <= end
        )
        if not matches:
            excluded += 1
            continue
        observed_runs.add(record["testRunId"])
        key = (record["processName"], record["pathName"], record["reason"])
        grouped[key]["count"] += 1
        grouped[key]["traceKinds"].add(record["traceKind"])
        grouped[key]["runIds"].add(record["testRunId"])
    rows = []
    for key in sorted(grouped):
        process, path_name, reason = key
        item = grouped[key]
        rows.append({
            "processName": process,
            "pathName": path_name,
            "reason": reason,
            "count": item["count"],
            "traceKinds": sorted(item["traceKinds"]),
            "runIds": sorted(item["runIds"]),
            "gate": "investigate" if reason not in {"user-requested", "system-action"} else "review-expected-exit",
        })
    missing_runs = sorted(required_runs - observed_runs)
    status = "blocked" if rows or missing_runs else "no-exit-observed"
    return {
        "status": status,
        "artifactSha256": artifact,
        "versionCode": version,
        "rows": rows,
        "missingRunIds": missing_runs,
        "excludedRecordCount": excluded,
    }

def main():
    if len(sys.argv) != 3:
        print("Usage: exit_gate.py EXIT_RECORDS_JSONL TEST_PLAN_JSON", file=sys.stderr)
        raise SystemExit(2)
    records_path = Path(sys.argv[1])
    plan_path = Path(sys.argv[2])
    if not records_path.is_file() or not plan_path.is_file():
        print("required input file missing", file=sys.stderr)
        raise SystemExit(2)
    report = build_report(load_records(records_path), load_json(plan_path))
    print(json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True))
    if report["status"] == "blocked":
        raise SystemExit(20)

if __name__ == "__main__":
    main()

事实依据与适用边界

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

本文判断事实或工程依据适用限制
进程退出记录可以提供退出原因、ANR trace 与部分 Native tombstone 材料。Android ApplicationExitInfo 描述进程退出信息和可用诊断数据。记录仍要绑定同一版本、进程、时间窗、用户路径与候选产物。
线上质量观察可以覆盖崩溃、ANR、启动和设备分布等信号。Android vitals 描述 Play 生态中的应用质量指标。样本受安装来源、用户同意和统计口径限制,不能替代发布前设备回归。
ANR 定位需要区分主线程阻塞、锁竞争、Binder、I/O 与组件超时。Diagnose Android ANRs 提供分层分析 ANR 的路径。trace 中的表象堆栈不一定是最初阻塞源,仍需关联其他线程与事件。
启动分析应区分 TTID、TTFD 与启动状态,并保存测量条件。Android app startup time 说明应用启动指标与阶段语义。单次耗时不能代表分位数,也不能证明加固是变化的唯一原因。
依赖 Android 运行时、组件和系统 API 的退出回归应在设备端执行。Android instrumented tests 说明设备测试可访问真实 Android 框架能力。单一设备通过不能代表完整 API、ABI、厂商和系统状态矩阵。
混淆 Java 或 Kotlin 堆栈需要与同一构建的 mapping 配对还原。R8 retrace 说明 retrace 的输入与映射职责。retrace 不处理 Native 符号,也不能修复错误候选或 mapping 身份。
退出原因只能分流调查任务,不能单独证明 VMP 或任一代码位置是根因。工程判断:同一系统分类可能由不同线程、业务路径、资源与系统条件触发。根因仍需同候选复现、完整 trace、符号和业务输入证据。
没有退出记录只有在采集成功且目标路径完成时才有回归意义。工程判断:采集不可用、路径未执行和观察窗错配都会产生假阴性。no-exit-observed 不是完整兼容结论,也不能外推到未覆盖设备。

工程常见问题

ApplicationExitInfo 显示 crash 就能证明加固导致崩溃吗?

不能。它只给出退出分类和部分材料。还要绑定候选摘要、版本、进程、时间窗和业务路径,并对 Java 栈、Native tombstone 或 ANR trace 做同构建符号化与复现。

为什么同一个 versionCode 还必须记录 APK 摘要?

同一 versionCode 可能对应不同打包、签名、保护配置或临时候选。只有 APK 摘要能把退出记录、mapping、符号和设备回执绑定到确切产物。

ANR 主线程停在受保护方法就能直接归因吗?

不能。主线程可能等待持锁线程、Binder、I/O 或远端服务,顶部栈不一定是最初阻塞源。需要完整线程、组件类型、时间线和同条件复测。

R8 retrace 能否还原 Native signal 地址?

不能。retrace 面向混淆后的 Java 或 Kotlin 堆栈。Native 信号需要对应 ABI、库摘要、Build ID、tombstone 和 Native 符号文件。

设备上没有读到退出记录是否可以判定通过?

只有采集功能正常、候选身份匹配、requiredRunId 全部执行、业务断言完成且观察窗有效时,才能记为 no-exit-observed;它仍不是未覆盖设备的兼容结论。

申请异常退出复核前需要准备哪些材料?

准备候选 APK、版本与进程表、测试动作和时间窗、退出记录、R8 mapping、Native 符号、ANR trace、设备范围和业务断言,再从御盾中央平台提交申请。

想用自己的 App 验证?

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

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