先看结论与判断条件

  • 协程调用的结果、异常、取消、清理和持久化副作用共同构成语义;只断言最终 UI 文案会漏掉父子 Job 与资源释放差异。
  • 取消是协作式行为,测试必须包含挂起点和主动检查路径,并验证 finally 清理完成,不能把测试线程结束当成协程已正确取消。
  • 超时既要核对外层收到的结果,也要核对内部任务是否仍执行、是否重复提交副作用以及清理代码是否被新的挂起操作打断。
  • launch、async、普通 Job 和 SupervisorJob 的异常传播与观察位置不同,不能使用一条统一的捕获断言覆盖所有 builder。
  • 进程退出不是 CancellationException。应用恢复后应依据持久化业务状态决定继续、补偿或停止,不能假定协程 finally 一定在进程死亡前运行。
  • 设备端 instrumented test、ApplicationExitInfo 和 Android vitals 提供不同证据层级,三者都必须绑定候选和场景,不能互相替代。

先冻结语义基线,而不是先增加重试次数

语义回归首先固定可比较对象:源码提交、构建变体、依赖解析、Kotlin 与协程库版本、加固配置、APK 摘要和签名证书摘要。原始包与加固包必须使用同一业务输入、调度条件和测试时钟;任一基线漂移都会让异常归属失去意义。

每个用例要写出可观察契约,包括返回值或最终状态、异常类型与归属 Job、子任务是否取消、清理动作、持久化副作用、允许的日志事件和禁止出现的状态。协程函数没有抛错不等于业务正确,后台子任务仍可能继续修改数据或重复调用接口。

Kotlin 协程指南描述挂起、调度、结构化并发、取消与异常传播的语言层语义,但不能证明加固变换后的状态机仍然等价。文章只讨论运行回归,不决定哪些协程状态机进入 VMP,也不把指南示例当成具体候选的测试回执。

协程语义对照必须固定的输入
对象固定字段比较方式漂移后处置
应用候选文件摘要与签名原始包和加固包一一对应停止归因
代码与依赖提交和解析版本使用同一基线重新构建对照
协程环境dispatcher、scope、Job 类型逐用例相同分开实验
测试时间真实或虚拟时钟策略同一超时控制结果不可直接比较
业务输入公开样本摘要复用同一数据拒绝合并回执
设备型号、系统和执行身份保存每次回执标记环境差异

正常完成路径也要检查挂起前后的状态

正常完成用例至少跨过一次真实或可控挂起点,避免只测试没有发生状态机切换的同步分支。记录挂起前业务状态、恢复后状态、返回值和持久化结果;如果函数要求特定 dispatcher 或线程,还要断言关键 UI 或数据操作发生在约定上下文。

重复调用要区分业务新请求和同一请求重入。测试可给每次业务意图稳定 operationId,确认恢复后没有重复写库、重复扣减或重复提交网络任务。幂等不是协程库自动提供的属性,状态机恢复正确也不代表业务副作用只发生一次。

正常路径是其他场景的语义基线。若原始包已经在给定设备和输入上失败,就不能把相同失败归因于加固;若加固包出现首个差异,应保留最早状态、Job 关系和异常,而不是立刻扩大超时或改用全局 scope 掩盖问题。

正常完成路径的业务断言
阶段观察对象通过条件常见假通过
调用前输入和初始状态与基线一致复用上次残留数据
首次挂起Job 与业务状态无提前提交只看到线程空闲
恢复执行上下文与中间状态满足调用契约不检查 dispatcher
函数完成返回或结果对象业务断言成立只断言未抛错
持久化数据库或队列状态副作用一次重复写入未统计
资源清理listener、流和锁按契约释放依赖进程结束

父子取消要验证传播、协作点和 finally 清理

协程取消是协作式的。挂起函数通常会检查取消状态,但纯计算循环如果没有挂起点或主动检查,可能继续执行。回归用例应分别覆盖在挂起点取消和在计算阶段取消,记录取消请求时间、子任务观察时间、停止后的业务状态与是否仍产生副作用。

父 Job 取消时,受其结构化并发约束的子协程应按契约终止;子协程自身失败是否取消兄弟或父任务,则取决于 scope 与 Job 类型。测试要保存父子标识和完成原因,不能仅凭一条 CancellationException 日志判断整棵 Job 树已经停止。

finally 中的同步清理与需要挂起的清理要分开验证。处于已取消上下文时,新的挂起操作可能立即再次收到取消;如果项目确实需要不可取消的短清理区,应明确其边界、失败处置和副作用。文章不提供通用清理时长,实际限制由资源与业务恢复能力决定。

取消回归矩阵的关键分支
场景取消触发核心断言失败信号
挂起点取消等待期间取消父 Job子任务停止并清理恢复后继续写状态
计算阶段取消循环执行时取消协作检查生效长期不响应取消
父取消scope 主动取消受管子任务结束遗留活跃 Job
子取消单个子任务取消影响符合 scope 契约兄弟状态异常
finally 清理取消后进入清理必要资源释放清理被静默跳过
重复取消多次触发取消终态幂等重复补偿或崩溃

超时回归要同时观察外层结果和内部任务

超时用例要控制触发点,使操作在明确的挂起或等待阶段越过时限。外层收到超时只证明调用者结束等待,不能证明底层网络、数据库事务或第三方回调已经停止。测试应在超时后继续观察一段由项目定义的窗口,确认没有迟到结果覆盖终态。

当业务使用超时返回空值或降级结果时,要区分正常无数据和真正超时。状态记录至少包含 operationId、开始、超时触发、内部任务完成或取消、补偿动作和最终业务状态。不能将所有超时转成 null 后让上层误判为查询成功但没有内容。

虚拟时间适合验证纯协程调度,但涉及 Android 组件、Binder、真实网络或系统 API 的语义仍需要设备端 instrumented test。两类证据应使用相同场景名称与候选摘要,分别说明调度断言和端到端结果,不能把本地虚拟时钟通过直接外推到真实设备。

超时路径的双层观察
检查点调用层断言内部任务断言禁止结论
触发前仍在等待任务处于预期阶段用 sleep 猜状态
超时发生返回或抛出符合契约收到取消或停止信号只看 UI 提示
清理阶段不重复提示资源按契约释放假定 finally 完成
迟到回调终态不被覆盖回调被丢弃或幂等处理忽略后台写入
重试复用或创建正确操作身份不重复副作用每次换新标识
恢复用户得到明确状态任务状态可查询把 null 当成功

launch 与 async 的异常不能用同一断言

Kotlin 异常处理文档说明,launch 与 async 对异常的暴露方式不同。launch 中未处理异常可能沿父级传播并到达异常处理器;async 将异常保存在 Deferred 中,通常在 await 时重新抛出。回归必须分别记录异常产生位置、观察位置和最终 Job 状态。

只验证日志里出现异常名称会产生假通过。对于 launch,要确认父 scope、兄弟任务和业务终态符合结构化并发契约;对于 async,要同时覆盖正常 await、延迟 await、未 await 前取消和 await 捕获后的状态。错误被捕获不代表副作用已经回滚。

CancellationException 参与取消传播,不应与业务故障混成同一告警。测试报告保留原始异常类型、cause 链的公开安全摘要、Job 完成原因和业务错误码;生产日志不能记录 token、用户输入或内部敏感数据。加固前后比较的是分类和传播,不是堆栈文本逐字相同。

协程 builder 的异常观察差异
结构异常观察点必须断言遗漏风险
launch父级或异常处理器父子终态和业务状态只记录日志
asyncawaitDeferred 与调用方状态从不 await
coroutineScope作用域调用点失败传播和兄弟取消只看失败子任务
supervisorScope各子任务观察点隔离与显式处理误以为自动吞异常
取消异常协作取消链不当作业务故障触发错误重试
清理异常finally 或 close保留原始失败关系覆盖首个异常

监督作用域要证明失败隔离而不是异常消失

SupervisorJob 或 supervisorScope 的价值在于一个子任务失败时可以按契约隔离其他子任务,但失败仍需被观察和处理。回归用例创建至少两个有独立业务断言的子任务,让其中一个在可控点失败,确认另一个是否继续、父作用域如何结束以及失败如何进入业务状态。

监督隔离不适合掩盖共享事务的一部分失败。如果两个子任务共同构成一个不可分割业务操作,允许一个成功一个失败可能留下半完成状态。工程判断应在测试前写清哪些任务可以独立成功,哪些需要统一补偿;测试只验证批准的契约。

异常处理器的位置同样重要。安装在不会接收根异常的位置,或者把异常转换成成功结果,会让仪表测试看似通过。回执应标出失败子任务、观察者、处理动作、兄弟任务结果和最终对用户可见状态,不用线程名猜测 Job 层级。

监督隔离场景的决策表
子任务关系一个子任务失败后兄弟任务业务终态
完全独立记录单项失败允许继续部分结果可解释
共享事务触发统一失败停止或补偿不得半完成
可降级功能标记降级原因核心任务继续显示受限状态
安全前置检查立即拒绝主流程不得继续敏感动作明确失败
后台预取记录可重试状态前台功能不受阻不冒充已完成
未知关系停止自动决定保留证据要求人工定义契约

进程退出与协程取消是两种证据链

系统终止进程时,应用不能假定所有协程 finally 都会完成。需要持久化的业务状态必须在关键提交点写入可靠存储,并能在新进程中判断操作是未开始、执行中、已完成还是需要补偿。用进程重启后的页面正常显示,不能证明退出前的后台任务没有重复或丢失。

ApplicationExitInfo 可以提供进程退出原因,并在支持版本上提供部分诊断材料,但记录必须与应用版本、进程、时间窗和用户路径关联。它不能直接告诉团队某个协程状态机是否正确;价值在于把进程级事实与测试时间线和业务状态拼接。

Android vitals 提供崩溃、ANR、启动和设备分布等线上质量信号,样本仍受安装来源、用户同意与统计口径限制。上线后可用它发现候选相关的异常模式,但不能用没有看到指标上升证明全部设备的协程语义已经通过。

用代码拒绝缺场景、缺断言和候选错配的回执

下面的 Python 脚本只读取公开安全的设备测试结果 JSON,不运行协程、不控制进程,也不接触生产日志。它要求每个候选覆盖正常完成、父子取消、超时、launch 异常、async await、监督隔离和进程退出,并校验预期状态、实际状态和业务断言。

同一 scenario 的调度器、Job 结构和测试时钟策略形成 contract。若不同结果对同一 contractId 给出不同配置,脚本直接拒绝,避免把变量漂移后的两次执行写成对照。真实项目可以扩展字段,但不能删除候选摘要、设备、场景、结构和断言。

脚本还拒绝包含 token、userInput、privateData 或 stackDump 的结果,促使团队只保存必要摘要与分类。结构门禁通过不代表协程实现通过;它只证明证据覆盖完整、可比较,最终语义仍由设备断言和业务负责人确认。

校验 Kotlin 协程取消与异常语义回归矩阵
import json
import re
import sys
from pathlib import Path

REQUIRED_SCENARIOS = {
    'normal_complete',
    'parent_child_cancel',
    'timeout_cleanup',
    'launch_uncaught',
    'async_await_failure',
    'supervisor_isolation',
    'process_exit_restore',
}
SENSITIVE = {'token', 'userInput', 'privateData', 'stackDump'}

if len(sys.argv) != 2:
    raise SystemExit('usage: validate_coroutine_matrix.py results.json')
input_path = Path(sys.argv[1])
if not input_path.is_file():
    raise SystemExit('results file is missing')
data = json.loads(input_path.read_text(encoding='utf-8'))
results = data.get('results')
if not isinstance(results, list) or not results:
    raise SystemExit('results array is missing')
coverage = {}
contracts = {}
for index, item in enumerate(results):
    if not isinstance(item, dict):
        raise SystemExit(f'result {index} is not an object')
    leaked = SENSITIVE.intersection(item)
    if leaked:
        raise SystemExit(f'result {index} contains sensitive fields: {sorted(leaked)}')
    required = {'candidateSha256', 'device', 'scenario', 'contract', 'expected', 'actual', 'assertions'}
    if not required.issubset(item):
        raise SystemExit(f'result {index} is missing required fields')
    candidate = str(item['candidateSha256']).lower()
    if not re.fullmatch(r'[0-9a-f]{64}', candidate):
        raise SystemExit(f'result {index} has an invalid candidate digest')
    scenario = str(item['scenario'])
    if scenario not in REQUIRED_SCENARIOS:
        raise SystemExit(f'result {index} has an unknown scenario')
    contract = item['contract']
    if not isinstance(contract, dict):
        raise SystemExit(f'result {index} contract is not an object')
    contract_fields = {'id', 'dispatcher', 'jobStructure', 'clockPolicy'}
    if not contract_fields.issubset(contract):
        raise SystemExit(f'result {index} has an incomplete contract')
    contract_id = str(contract['id'])
    stable_contract = json.dumps(contract, ensure_ascii=False, sort_keys=True, separators=(',', ':'))
    if contract_id in contracts and contracts[contract_id] != stable_contract:
        raise SystemExit(f'contract {contract_id} changes between results')
    contracts[contract_id] = stable_contract
    assertions = item['assertions']
    if not isinstance(assertions, list) or not assertions or any(not str(value).strip() for value in assertions):
        raise SystemExit(f'result {index} has no substantive assertions')
    if item['actual'] != item['expected'] and not str(item.get('errorType', '')).strip():
        raise SystemExit(f'result {index} failed without an error type')
    coverage.setdefault(candidate, set()).add(scenario)
for candidate, scenarios in coverage.items():
    missing = sorted(REQUIRED_SCENARIOS - scenarios)
    if missing:
        raise SystemExit(f'candidate {candidate} lacks scenarios: {missing}')
print(json.dumps({'candidates': len(coverage), 'contracts': len(contracts), 'status': 'pass'}))

发布门禁必须同时看到语义、进程和线上边界

发布前把七类场景按原始包与加固包成对执行,所有回执绑定同一候选、测试契约和设备。通过条件包括业务断言一致、异常归属符合设计、取消后无迟到副作用、进程恢复不重复提交,以及任何失败都有可定位的场景与错误类型。

若差异只在单一调度器或设备出现,先保持其他变量不变复现,再缩小到具体 suspend 点、Job 关系或恢复状态。增加 timeout、改成 GlobalScope、吞掉异常或重跑至通过都不是修复证据。一次失败也不能仅因后续通过而从报告删除。

需要继续定位线程阻塞或退出问题时,可结合本站的加固后 ANR 分层排查指南核对主线程、锁和进程证据,再携带脱敏矩阵、候选摘要与最早差异申请御盾技术评估。所有申请与控制台动作由御盾中央平台承接,文章不宣称任何未取得的兼容结果。

事实依据与适用边界

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

本文判断事实或工程依据适用限制
协程运行语义由挂起、调度、结构化并发、取消和异常传播共同构成。Kotlin coroutines guide 说明协程的核心概念与结构。语言指南不证明加固后的具体状态机仍保持原语义。
协程取消是协作式行为,挂起点和取消检查会影响停止与清理。Kotlin coroutine cancellation 说明取消、超时、finally 和协作检查语义。通用取消规则不能替代目标函数、调度器和业务副作用的逐路径回归。
launch、async、父子 Job 与 CancellationException 的异常观察和传播方式不同。Kotlin coroutine exception handling 说明不同 builder 和 Job 结构的异常语义。文档示例不能证明某一加固变换实现正确,也不定义项目业务错误码。
依赖 Android 运行时、组件和系统 API 的语义应在设备环境执行测试。Android instrumented tests 说明设备端 instrumented test 的运行环境与用途。单一设备通过不能代表完整 API、ABI、厂商、账号和网络矩阵。
进程退出原因和部分诊断材料可通过系统记录关联到应用进程。Android ApplicationExitInfo 说明退出原因、ANR trace 与可用诊断字段。退出记录仍需绑定应用版本、时间窗和用户路径,不能直接证明协程根因。
线上质量信号可以观察崩溃、ANR、启动和设备分布。Android vitals 说明相应质量指标与呈现范围。样本受安装来源、用户同意和统计口径限制,没有告警不等于全部设备通过。
协程回归必须同时断言业务终态、异常归属、子任务状态、清理和持久化副作用。工程判断:只看函数返回或 UI 文案无法发现迟到回调、兄弟取消和半完成状态。断言集合由业务契约决定,文章不提供所有应用通用的通过模板。
进程退出后的恢复不能依赖 finally 必然执行。工程判断:进程被终止时应用代码没有可靠机会完成所有清理,关键状态必须可持久恢复。具体继续、补偿或停止策略取决于业务幂等、数据模型和服务端状态。

工程常见问题

加固后协程接口最终返回正确,是否可以判定回归通过?

不可以。还要检查父子 Job、异常归属、取消后迟到副作用、finally 清理、持久化状态和进程恢复,最终返回只是语义的一部分。

协程取消后为什么任务仍可能继续一段时间?

取消是协作式的。挂起点通常检查取消,但纯计算或未主动检查的代码可能继续执行;测试需要覆盖实际协作点并观察停止后的副作用。

把超时时间调大能否证明加固兼容问题已经修复?

不能。调大超时只是改变条件,可能掩盖迟到任务或阻塞。应保持其他变量不变,定位具体挂起点、调度器、Job 关系和清理路径。

SupervisorJob 会自动处理所有子协程异常吗?

不会。它改变失败传播和隔离关系,但异常仍需在正确位置观察并转换为明确业务状态;共享事务还可能需要统一补偿。

ApplicationExitInfo 能直接定位协程状态机错误吗?

不能。它提供进程级退出事实和部分材料,需要与候选、测试时间线、Job 结构和业务状态关联后,才能支持进一步判断。

申请御盾协助定位协程回归需要准备什么?

准备候选摘要、协程与依赖版本、dispatcher 和 Job 结构、场景矩阵、业务断言、最早差异、退出记录及脱敏错误类型,再通过御盾中央平台提交。

想用自己的 App 验证?

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

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