先看结论与判断条件

  • 默认工程顺序是编译输入进入 R8,冻结 R8 输出与 mapping 后解析 VMP 目标,再做 VMP、最终打包和签名;工具明确支持其他插入点时才按其契约例外处理。
  • R8 负责代码和资源缩减、优化与名称混淆;VMP 负责当前工具支持范围内的选定方法变换,二者不应重复声明对方职责。
  • 源码方法名单不能直接充当 VMP 最终目标,因为 R8 可能删除、内联、合并或重命名方法;必须保存源码意图到 post-R8 身份的映射。
  • 反射、JNI、序列化和间接入口使用精确 keep 规则维持可达性,不能为了让 VMP 方便而全局 keep,也不能把 keep 规则当作保护名单。
  • mapping、R8 规则、post-R8 清单、VMP 目标、变换报告、最终 APK 摘要和签名身份必须来自同一条不可混用的构建链。
  • 测试应分为未优化基线、R8-only、R8-plus-VMP 和最终签名候选四层,使裁剪错误、保护变换错误和打包签名错误可以独立定位。

默认先完成 R8,再对稳定的 post-R8 方法做 VMP

当 VMP 以 R8 输出的 DEX 或等价中间产物为输入时,最清晰的顺序是:先编译并由 R8 完成可达性裁剪、优化和名称混淆,保存 mapping 与规则,再从该确切输出解析 VMP 方法,完成变换后进入最终打包、对齐和签名。这样 VMP 看到的是将要交付的真实方法集合,不会保护已被 R8 删除的方法,也不会依赖尚未稳定的名称。

这不是不考虑工具差异的绝对法则。有些 VMP 以 Gradle 插件、字节码变换或编译器阶段接入,可能要求在 R8 之前标记或处理方法。此时应严格遵循当前版本的支持契约,并证明标记如何穿过 R8、生成代码如何被 R8 对待、最终方法如何追溯。不能把其他产品或旧版本的顺序直接复制到当前流水线。

无论插入点如何,责任边界都不变:R8 决定哪些代码可达、如何优化和命名;VMP 只变换被选中的方法;打包与签名确定最终候选身份。门禁应让每个阶段消费上一阶段的摘要并产生新的摘要,任何报告无法绑定该链时都保持未确认。看到任务执行成功或 APK 可安装,不足以证明目标方法确实进入最终候选。

默认流水线与允许例外
阶段默认输入主要输出例外条件
编译源码与依赖未优化类或字节码按项目工具链
R8编译产物与规则优化代码、mapping、usage不能被 VMP 隐式替代
目标解析post-R8 产物与映射VMP 目标清单编译期插件需提供等价追踪
VMP稳定目标与输入摘要变换产物和报告只按受支持插入点
打包签名全部最终变换产物发布候选签名后不得再改候选
验证确切最终候选静态与设备回执中间产物不能替代

R8 的工作是缩减、优化和混淆,不是方法虚拟化

Enable app optimization with R8 将 R8 的职责描述为代码和资源缩减、优化与名称混淆。缩减移除不可达代码和未使用资源,优化改变可执行结构,混淆缩短或改变类、字段和方法名称。发布构建需要保存规则与输出,使团队能够解释某个入口为何被保留、某个方法为何消失以及崩溃名称如何还原。

R8 的通过标准不是构建成功,而是优化后的候选仍满足应用语义。反射、JNI、序列化、依赖注入、布局引用和其他间接入口可能不被静态分析自动发现,需要精确规则和真实测试。若为了消除崩溃直接 keep 整个包,虽然短期恢复运行,却会掩盖真实可达性边界并削弱缩减与优化价值。

R8 也不提供 VMP 的职责。名称混淆改变标识符,可达性优化改变结构,但它们不等于把选定业务方法转换为某个虚拟指令解释模型。反过来,VMP 也不应负责推断全局可达性或替代 R8 的资源缩减。把两者写成两层独立控制,才能分别配置、测试和回滚。

R8 输出与后续用途
输出回答的问题VMP 如何使用不能推出
优化代码最终可达结构是什么作为默认变换输入所有语义已回归
mapping原始名称如何映射追踪源码意图和崩溃Native 符号可还原
usage/seed 线索删除和保留情况发现目标漏选原因VMP 保护强度
规则集合为何保留间接入口验证目标依赖边界规则越宽越安全
资源缩减结果未使用资源如何处理通常不属于 VMP资源运行语义通过
工具版本哪个 R8 产生输出构建链身份字段版本相同即字节相同

VMP 只处理选定方法,不能补救错误的可达性和授权

VMP 在本文中指当前工具对选定 Java、Kotlin 或可支持方法进行虚拟化或等价保护变换。它的输入必须是明确的方法身份、字节码前提和支持边界,输出必须列出成功、跳过和失败目标。VMP 不负责判断整个应用哪些类应该存在,也不能凭保护名单恢复已经被 R8 删除或内联的方法。

选定方法应承载高价值业务决策,并具有窄输入、少反射、少框架回调和可独立测试等特征。Activity 生命周期、序列化生成代码、协程状态机、JNI 桥、反射入口或大型第三方库需要谨慎,除非当前项目拥有工具级支持与真机证据。把全包方法都送入 VMP 会扩大兼容和性能风险,也让真正的保护重点难以审计。

OWASP MASVS-RESILIENCE 把抗逆向与抗篡改放在纵深防御控制域。VMP 可以是其中一种措施,却不能替代服务端授权、签名发布、密钥管理、输入验证或完整测试链。没有当前候选的攻防和设备证据时,只能陈述方法被变换及其边界,不能量化逆向难度或声称攻击已经被阻断。

R8 与 VMP 的责任对照
问题R8 责任VMP 责任其他责任
不可达代码分析并缩减不恢复测试证明语义
名称变化生成混淆映射消费映射后的身份归档 mapping
方法变换优化但非 VMP 契约保护选定方法报告成功与跳过
反射/JNI精确 keep 保持入口按支持边界选择运行测试
最终授权不负责不负责服务端与业务系统
发布身份提供中间输出提供变换输出打包签名和候选归档

源码意图必须映射到 post-R8 身份,再形成 VMP 清单

产品或安全团队通常以源码方法描述保护意图,例如某个许可证判断、风控状态机或权益映射。R8 之后,这个方法可能被重命名、内联、合并、移动或移除。直接把源码全限定名交给 post-R8 VMP 工具,可能得到未找到、保护错误目标或静默漏选。目标解析必须读取同一构建的 mapping 和 post-R8 方法清单。

推荐保存三层身份:sourceIntentId 表示稳定业务意图,sourceSymbol 记录源码签名,postR8Symbol 与 codeDigest 记录优化后的实际对象。若方法被内联或移除,解析器应返回明确状态,并要求重新选择可保护边界;不能自动选择名字相近的其他方法。若工具支持注解或规则跨阶段传播,也要验证最终报告能够回指 sourceIntentId。

VMP 目标清单本身应不可变,包含 post-R8 输入摘要、方法身份、选择理由、支持前提和期望处理方式。变换完成后,报告逐条给出 protected、skipped 或 failed,并产生输出摘要。只有清单与报告一一闭合,才能确认没有因 R8 变化漏掉高价值方法,也没有把调试、框架或错误重载纳入保护。

方法身份的三层映射
标识来源失败处理
业务意图sourceIntentId内部保护台账不得自动替代
源码方法类、方法、描述符源码与编译输入记录重载精确签名
R8 输出postR8Symbol、codeDigest同批 mapping 与产物内联或删除需重选
VMP 目标targetId 与期望动作不可变目标清单未解析即停线
变换结果状态与输出定位VMP 报告skip 和 fail 不算通过
最终候选APK 摘要与签名打包发布阶段无法绑定则拒绝

keep 规则只维持可达性,不负责决定保护范围

R8 keep rules best practices 强调为反射、JNI 和其他间接入口编写精确规则。规则应说明哪个框架或调用路径需要保留哪些类、成员或属性,并尽量缩小范围。VMP 候选依赖的构造器、接口、反射入口或 Native 注册点需要单独分析,但不能因为候选方法重要就 keep 整个业务包。

keep 与 protect 是两个不同集合。keep 表示 R8 不得移除或必须保留某些优化或命名属性,protect 表示 VMP 要对 post-R8 目标执行变换。一个方法可能需要 keep 但不值得 VMP,例如序列化构造器;也可能值得保护但不需要保留原名。配置文件应分开维护,门禁阻止把同一通配规则同时解释成两个集合。

JNI 还需要绑定 Native 侧方法查找或注册方式。若 Native 通过字符串寻找 Java 方法,R8 规则必须维持契约,VMP 也要证明变换后的入口与调用约定仍然兼容。R8 mapping 不能替代 Native 符号和注册证据。没有目标 ABI 的真实设备回执时,只能确认静态规则和清单一致,不能登记 JNI 路径通过。

keep 与 protect 的交集处理
场景keep 决定VMP 决定验证
普通直接调用通常无需特殊规则按业务价值选择单元与设备测试
反射入口保留被查找对象谨慎选择窄函数反射回归
JNI 注册保持 Java/Native 契约仅按工具支持目标 ABI 真机
序列化模型保留构造和字段契约通常排除编解码回归
框架生命周期按框架要求通常排除启动与恢复
高价值纯函数由可达性决定优先评估保护前后等价

mapping、变换报告和候选摘要必须同批归档

R8 retrace 使用 mapping 将混淆后的 Java 或 Kotlin 堆栈还原,因此 mapping 必须与产生崩溃的同一构建配对。VMP 加入后,崩溃定位还可能需要变换报告、目标映射和工具日志。若 mapping 来自另一构建,即使源码提交相同,也可能因为依赖、R8 版本或规则差异还原出错误结果。

建议为每个阶段保存输入摘要、配置摘要、工具版本、输出摘要和时间,并使用上一步输出摘要作为下一步输入。R8 阶段附 mapping 与规则摘要,目标解析附 post-R8 清单与 target list 摘要,VMP 阶段附逐方法状态和输出摘要,打包签名阶段附最终 APK 摘要与证书摘要。报告只能引用这些不可变标识。

NIST SP 800-218 SSDF 强调来源、构建、验证与变更证据。落实到这条流水线,任何重新运行都会产生新的 release identity,不能把旧 mapping、旧 VMP 报告或旧设备回执复制到新 APK。若签名后又修改包内容,最终候选身份改变,必须重新进入相应门禁,不能只补一份摘要文件。

单次构建需要归档的证据
阶段输入绑定不可缺少输出错配后果
R8编译产物与规则优化产物、mapping、规则摘要目标和 retrace 错误
目标解析R8 输出与 mappingpost-R8 清单、目标清单漏选或错选
VMP目标与输入摘要逐方法报告、输出摘要无法证明保护对象
打包全部变换产物未签名候选摘要内容来源不清
签名确切未签名候选APK 与证书摘要发布身份错配
测试最终 APK 摘要设备、用例与结果中间产物冒充上线包

四层回归把 R8 错误与 VMP 错误分开定位

第一层是未优化或可诊断基线,用于确认业务用例本身可运行;第二层是 R8-only 候选,用于发现 keep、优化、反射和 JNI 问题;第三层是 R8-plus-VMP 产物,用同一用例检查选定方法变换;第四层是最终打包签名候选,覆盖安装、升级、启动、恢复和真实分发入口。四层的摘要与配置必须分别保存。

Android instrumented tests 适合验证依赖真实 Android 运行时、组件和系统 API 的语义。回归应覆盖正常路径、异常输入、进程恢复、反射、序列化、JNI、并发和目标设备矩阵,并记录保护前后输出与异常类型。单一设备通过不能代表全部 API、ABI 和厂商环境,静态方法出现在 DEX 也不能代表运行路径正确。

故障定位按最早失败层处理。R8-only 已失败就先修 keep 或优化边界,不应通过取消 VMP 掩盖;R8-only 通过而 VMP 层失败,则检查目标适配、异常语义和工具支持;只有最终签名层失败时,再查打包、签名或安装流程。任何修复都会生成新候选并重跑后续层,禁止给旧结果改日期。

  • 未优化、R8-only、R8-plus-VMP 和最终候选分别有摘要
  • 每层使用相同核心业务与异常用例
  • 反射、序列化、JNI 和恢复路径单独覆盖
  • 失败归因到最早出现差异的阶段
  • 修复后生成新候选并重跑所有后续门禁
  • 最终线上结论只绑定签名 APK 的设备回执

用构建链校验器阻止顺序错误和证据错配

下面的 Python 示例读取一份构建证据 JSON,要求阶段严格为 compile、r8、target-resolution、vmp、package、sign。每一阶段的 inputSha256 必须等于上一阶段 outputSha256;R8 必须提供 mapping 与规则摘要,目标解析必须声明 post-r8 基础并提供目标清单,VMP 必须引用该清单并提供逐方法报告,最终声明的 APK 摘要必须等于签名阶段输出。

脚本只验证证据链内部一致,不执行 R8、VMP、打包或签名,也不能证明摘要对应的文件真实存在。生产流水线应在每阶段由受信任务重新计算文件摘要,并将工具版本、命令参数和不可变配置一同签入构建记录。若当前 VMP 工具要求编译期插入,应使用该工具专用 schema 表达顺序,不能为了通过示例而伪造 post-R8 输入。

申请 R8 与 VMP 的商业加固评估时,可准备最终 APK、构建任务、R8 规则与 mapping、源码意图清单、post-R8 方法清单、VMP 报告、签名身份和四层回归结果,再从御盾中央平台提交申请。真实候选和回执齐备前,不宣称优化兼容、方法保护完成、逆向成本提高或攻击已被阻断。

  • 阶段顺序由当前工具链契约明确记录
  • 每一阶段输入摘要等于上一阶段输出摘要
  • R8 mapping、规则与输出来自同一构建
  • VMP 目标基于 post-R8 身份并逐条闭合
  • 最终 APK、签名证书和测试回执绑定一致
  • 静态链通过后继续执行四层运行回归
校验 R8、VMP、打包与签名阶段的摘要绑定
from pathlib import Path
import json
import re
import sys

DIGEST = re.compile(r"^[0-9a-f]{64}$")
EXPECTED_STAGES = ["compile", "r8", "target-resolution", "vmp", "package", "sign"]

def load_record(path_text):
    path = Path(path_text)
    if not path.is_file():
        raise SystemExit(2)
    try:
        value = json.loads(path.read_text(encoding="utf-8"))
    except (OSError, json.JSONDecodeError):
        raise SystemExit(2)
    if not isinstance(value, dict) or not isinstance(value.get("stages"), list):
        raise SystemExit(2)
    return value

def valid_digest(value):
    return isinstance(value, str) and DIGEST.fullmatch(value.lower()) is not None

def require_digest(stage, field):
    value = stage.get(field)
    if not valid_digest(value):
        raise SystemExit(3)
    return value.lower()

if len(sys.argv) != 2:
    raise SystemExit(2)
record = load_record(sys.argv[1])
stages = record["stages"]
if [stage.get("name") for stage in stages] != EXPECTED_STAGES:
    raise SystemExit(3)
previous_output = None
for index, stage in enumerate(stages):
    if not isinstance(stage, dict) or not isinstance(stage.get("toolVersion"), str):
        raise SystemExit(3)
    current_input = require_digest(stage, "inputSha256")
    current_output = require_digest(stage, "outputSha256")
    if index > 0 and current_input != previous_output:
        raise SystemExit(4)
    previous_output = current_output
r8 = stages[1]
targets = stages[2]
vmp = stages[3]
for field in ("mappingSha256", "rulesSha256"):
    require_digest(r8, field)
if targets.get("selectionBasis") != "post-r8":
    raise SystemExit(4)
target_list = require_digest(targets, "targetListSha256")
require_digest(targets, "sourceIntentMapSha256")
if vmp.get("targetListSha256", "").lower() != target_list:
    raise SystemExit(4)
require_digest(vmp, "methodReportSha256")
if not isinstance(vmp.get("protectedCount"), int) or vmp["protectedCount"] <= 0:
    raise SystemExit(4)
if not isinstance(vmp.get("skippedCount"), int) or vmp["skippedCount"] != 0:
    raise SystemExit(4)
final_apk = record.get("finalApkSha256")
if not valid_digest(final_apk) or final_apk.lower() != stages[-1]["outputSha256"].lower():
    raise SystemExit(5)
if not valid_digest(record.get("signingCertificateSha256")):
    raise SystemExit(5)
print(json.dumps({"status": "eligible-for-device-gates", "finalApkSha256": final_apk.lower(), "protectedCount": vmp["protectedCount"]}, ensure_ascii=False))

事实依据与适用边界

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

本文判断事实或工程依据适用限制
R8 的职责包括代码和资源缩减、优化与名称混淆,发布构建需要保存相关规则和输出。Enable app optimization with R8 描述 R8 优化、缩减与混淆能力及发布使用方式。R8 编译优化不等于 VMP,也不证明抗动态分析、业务授权或运行兼容。
反射、JNI 和间接入口需要精确 keep 规则,宽泛规则会削弱优化并掩盖边界问题。R8 keep rules best practices 给出 keep 规则的精确化方向和常见入口考虑。keep 规则只描述 R8 可达性与优化约束,不定义 VMP 保护范围或强度。
混淆后的 Java 与 Kotlin 崩溃需要使用同一构建产生的 mapping 进行还原。R8 retrace 描述使用 mapping 文件还原混淆堆栈的方法。retrace 不处理 Native 符号,也不能修复 mapping、VMP 报告或 APK 候选身份错配。
依赖真实 Android 运行时、组件和系统 API 的语义需要设备端测试。Android instrumented tests 说明设备测试用于验证依赖 Android 运行环境的行为。单一设备通过不能代表完整 API、ABI、厂商和保护目标矩阵。
抗逆向和抗篡改属于移动端纵深防御,不能替代服务端授权和完整发布链。OWASP MASVS-RESILIENCE 描述移动应用韧性控制域。控制目录不证明当前候选达到任何防护强度,也不提供御盾产品验收结论。
安全发布流程应保存来源、构建、验证和变更证据。NIST SP 800-218 SSDF 描述安全软件开发中的构建、来源、验证和变更实践。SSDF 是组织级框架,不定义 R8 与 VMP 的具体插入点或某个加固产品功能。
当 VMP 消费 R8 输出时,应先冻结 R8 产物和 mapping,再解析 VMP 方法清单。工程判断:R8 可能删除、内联或重命名源码方法,post-R8 身份才能对应真实变换输入。编译期插件可能使用其他受支持插入点,必须以当前工具契约和最终映射证据为准。
R8 输出、目标清单、VMP 报告和最终 APK 必须形成连续摘要链。工程判断:连续输入输出绑定可以阻止旧 mapping、旧报告或中间产物冒充当前发布候选。摘要链证明记录一致性,不单独证明工具可信、运行语义正确或保护强度达标。

工程常见问题

R8 和 VMP 到底谁先执行?

若 VMP 以 R8 输出为输入,默认先 R8,再按 post-R8 身份解析目标并执行 VMP,最后打包签名。若工具明确要求编译期插入,则按当前版本契约执行并证明最终映射。

R8 已经混淆名称,为什么还需要 VMP?

两者职责不同。R8 负责缩减、优化和名称混淆;VMP 对选定方法做工具支持的保护变换。是否采用 VMP 取决于业务价值、威胁模型和兼容证据。

能否直接用源码方法名作为 VMP 目标?

不能假设可以。R8 可能重命名、内联、合并或删除方法。应保存源码意图,并用同一构建的 mapping 和 post-R8 清单解析最终目标。

为了避免漏选,把整个业务包都 keep 是否更稳?

通常不合适。宽泛 keep 会掩盖反射或 JNI 的真实可达性需求并削弱优化。keep 与 protect 应分别维护,用精确规则保留入口,用独立目标清单选择 VMP。

VMP 报告显示方法成功,是否说明发布完成?

不能。还要把报告绑定最终 APK 与签名身份,并完成 R8-only、R8-plus-VMP 和最终候选的静态与设备回归。中间阶段成功不是线上结论。

提交 R8 与 VMP 加固评估需要哪些资料?

准备最终 APK、构建任务、R8 规则与 mapping、源码保护意图、post-R8 方法清单、VMP 报告、签名身份和四层回归结果,再通过御盾中央平台提交申请。

想用自己的 App 验证?

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

继续阅读: VMP 加固是什么,如何选择保护范围