先看结论与判断条件

  • 启动慢与交互掉帧是不同问题,TTID、TTFD、页面帧时长和输入响应要分别建账,不能合成一个流畅度分数。
  • 加固前后必须固定源码、构建变体、签名、设备、系统、刷新率、温控、页面数据、网络替身和热身策略。
  • 平均值会掩盖少量严重长帧,应同时比较中位数、高分位、异常帧比例、连续异常帧和逐迭代分布。
  • 门禁阈值由目标页面、刷新率和业务体验定义,公开方法不提供脱离项目证据的统一毫秒或比例结论。
  • 新增长尾需要回到同次 trace,区分主线程计算、锁等待、I/O、GC、布局、渲染线程和设备降频,不能从保护后出现直接推导根因。
  • 线上 Android vitals 适合观察范围与版本分布,真正放行仍要绑定候选摘要、测试进程、设备矩阵、原始样本和比较回执。

先把启动耗时与页面帧卡顿拆成两套指标

用户说打开后很卡,可能指启动首屏慢、首屏内容迟迟未完整、滑动掉帧、动画停顿、点击后长时间无反馈,或一次偶发冻结。这些现象经过的系统路径不同。启动测试关注进程创建、首帧和业务完全绘制;页面交互关注每一帧是否在当前刷新周期内完成;输入响应还要看事件到界面变化的链路。把它们合成一个平均耗时,会让启动改善掩盖滚动长帧,也会让一次冷启动污染稳态页面结果。

Android 启动文档区分 TTID 与 TTFD。TTID 对应首帧展示,TTFD 依赖应用报告完全绘制点,两者必须在相同冷、温、热状态和业务就绪定义下比较。页面帧回归则从目标 Activity 已进入稳定状态后开始,预先完成必要加载或明确把加载作为场景的一部分。加固前后都执行相同准备,不能让原始包测热路径、加固包测首次编译或首次资源解码。

验收报告为每类现象单独给出口:启动指标失败进入启动路径诊断,交互帧失败进入帧 trace,点击响应失败进入事件和任务链。本文只回答页面卡顿与掉帧的对照方法,不扩写下载体积或包大小影响,也不把 ANR 作为普通长帧处理。若页面出现长时间无响应,应转入对应的 ANR 与线程阻塞证据流程。

启动、帧卡顿与输入响应的指标边界
用户现象主要指标测量起止不可替代项
首次看到界面慢TTID启动到首帧不能替代完整内容
首屏业务迟到TTFD启动到报告完全绘制不能替代交互帧
滚动不连贯帧时长分布稳定页面交互区间不能用启动时间解释
动画偶发停顿长帧与连续异常帧动画开始到结束不能只看平均帧率
点击反馈慢输入到可见变化事件到目标状态不能只看 RenderThread
长时间无响应ANR 与线程 trace系统超时窗口不能降级为普通掉帧

固定页面路径、数据状态与热身策略

页面性能只有在路径可重复时才可比较。脚本应描述从哪个入口进入、滚动距离、手势速度、停留点、列表数据规模、图片缓存、登录状态、权限状态和网络响应。测试数据使用稳定脱敏夹具或受控服务替身,不依赖实时推荐和线上账号。若页面内容每次不同,布局数量、图片解码和绑定成本都会变化,即使候选完全相同也会得到不同帧分布。

热身策略要写入场景身份。冷路径可以清进程、缓存和编译状态,模拟首次使用;热路径保持进程和资源,观察重复交互;预热路径则在采集前执行固定次数并丢弃预热样本。三者都可以有业务价值,但不能互相比较。加固可能影响类加载或代码路径首次执行,若只测试热路径会遗漏首轮抖动;若只测首次路径,又无法判断稳定滚动。测试计划应分别命名而不是混入一个样本池。

交互脚本还要约束用户输入与后台噪声。手势由自动化以同一轨迹和节奏执行,通知、同步、定位和系统更新尽量受控。若业务必须包含网络或后台任务,则两侧使用相同响应和调度前置条件,并把它们标记为场景组成。任何中途弹窗、页面未到目标状态或数据条数变化都应使迭代无效,而不是继续计算帧时长。

页面场景身份的固定字段
维度示例记录变化影响门禁动作
页面入口路由和前置页面导航与初始化路径必须一致
数据夹具版本和内容摘要布局、图片与绑定量不同则不可比
手势脚本动作序列摘要帧负载和持续时间失败迭代作废
缓存状态冷、热或预热解码、编译与 I/O分开统计
网络策略离线夹具或固定响应等待和数据变化两侧同源
页面终态可见控件与数据计数采集区间是否有效未到达则失败

候选与设备条件必须精确到刷新率和温控

候选身份至少包含 APK 摘要、源码、构建变体、签名和加固策略摘要。原始包应是实际加固输入,不能重新构建一个近似版本。设备侧记录型号、系统构建、ABI、显示刷新率、电源状态、温控状态、动画设置和可用存储。高刷新设备每帧预算与低刷新设备不同,系统动态切换刷新率也会改变异常帧判断;采集时必须记录实际显示条件,而不是只写设备营销规格。

温控和电源会直接改变 CPU、GPU 调度。连续跑大量迭代可能让后测候选承受降频,固定总是先测原始包会形成顺序偏差。可以在设备恢复到受控状态后交替执行两份候选,或随机化候选顺序,并把每轮温度与频率证据附到回执。若设备无法恢复,应停止比较而不是继续累计更多受污染样本。性能门禁追求可解释数据,不是追求样本数量最大。

云真机目录与可用容量会变化,测试计划因此要保存实际分配的型号和 OS 回执。设备可用只说明平台当时能够分配,不代表覆盖目标用户的厂商渲染、GPU 驱动或高刷新特性。核心设备应根据真实支持范围选择,并允许保留自有设备补充。单一云设备与单次迭代通过不能外推全部机型。

帧回归的候选与设备可比条件
条件原始候选加固候选不一致处理
源码与构建变体记录摘要保持相同停止归因
APK 与签名实际加固输入最终发布候选重新绑定报告
设备与 OS实际分配回执同型号同构建标记不可比
ABI 与渲染路径记录实际路径保持相同拆分矩阵
刷新率采集时实际值同一实际值拒绝帧阈值比较
温控与电源受控范围受控范围冷却后重新执行

保留逐帧样本,并用分位数与长尾描述体验

平均帧时长会把少量严重长帧稀释。一个页面大部分帧很快,但每次滚动都出现一次明显停顿,平均值仍可能看起来正常。报告应保留每轮逐帧或聚合到足够细粒度的脱敏样本,至少给出样本数、中位数、高分位、最大值、超过项目异常阈值的比例和连续异常帧段。每个统计量都绑定迭代与场景,不把多个设备或刷新率混成一个总体。

分位数需要足够且独立的样本。把同一次动画的相邻帧当成完全独立观测,会低估场景间差异;更稳妥的做法是先按迭代计算摘要,再观察各迭代分布,同时保留逐帧用于定位。样本较少时应展示原始点与不确定性,不能用漂亮的小数位营造确定性。公开文章不提供统一阈值,团队应依据页面类型、刷新周期、用户体验和既有基线设定项目预算。

异常帧比例也要有明确分母。只统计采集成功的有效帧,剔除页面未到目标状态或 trace 丢失的整次迭代,并报告剔除原因。不能从加固包删除最差迭代、原始包保留全部,也不能把 warmup 帧只从一侧排除。比较脚本应读取两份相同元数据,发现设备、路径、刷新率、热身策略或阈值不同就拒绝输出差异。

帧样本的统计输出与用途
统计量回答问题需要保留常见误用
样本数数据是否完整逐迭代计数越多就一定可靠
中位数典型帧表现同场景分布代替长尾
高分位较差帧表现分位算法和样本小样本过度精确
最大值极端停顿线索对应 trace 时间点单点直接归因
异常帧比例超预算频度阈值与分母跨刷新率比较
连续异常帧可感知停顿段连续区间与动作只报总数量

比较差异前先定义项目预算和阻断规则

性能门禁需要两个维度:绝对体验预算和相对基线变化。加固候选即使与原始包相近,若两者都超过页面预算,仍不能写成体验合格;加固候选绝对值尚可,但长尾相对基线明显变化,也需要调查。预算由产品页面、刷新率、用户任务和设备档位决定,并在测试前冻结。看完结果再修改阈值,会把门禁变成解释工具。

相对差异不等于因果。原始与加固候选的高分位、异常帧比例或连续长帧有差异时,先检查条件一致、迭代顺序和不确定性,再进入 trace 定位。若置信不足,结论应是需要补样本或条件不可比,而不是加固导致卡顿。没有同环境原始基线时,可以报告加固包观察到的分布和是否满足绝对项目预算,但不能声称提高、降低或无影响。

阻断规则写成机器可执行字段,例如 metadata 必须完全匹配,最小有效迭代数满足要求,关键分位变化不超过项目预算,异常帧比例变化不超过项目预算,且没有缺失 trace。任一规则失败输出具体字段和样本摘要,不能自动重跑到通过。环境错误可按预先策略重试,但所有 attempt 都保留,让评审看到不稳定性和剔除理由。

帧性能门禁的决策层
层级输入通过含义失败后动作
可比性候选与环境元数据允许计算差异修正条件后重跑
样本完整性迭代、帧和剔除回执数据可解释补齐或阻断
绝对预算加固候选分布当前场景满足项目线定位页面瓶颈
相对预算原始与加固差异未发现超线回归进入 trace 调查
设备矩阵逐组合结果列出组合通过不外推缺失设备
发布回执输入摘要与规则版本结论可复核输入变化后重审

新增长尾要回到同次 trace 找到耗时责任

发现加固候选新增长帧后,第一动作不是扩大样本或关闭保护,而是定位长帧对应的 trace 区间。主线程可能在布局、测量、资源解码、锁等待、同步 I/O、Binder 或 JNI;RenderThread 和 GPU 也可能承担瓶颈;GC、类加载与首次编译会形成阶段性停顿。帧统计只说明何时超出预算,不能从一个长帧数字推导具体函数,更不能从加固后首次出现直接写成保护根因。

诊断要比较同一页面动作在两份候选中的时间线。若原始包也在同位置有峰值,只是未跨阈值,可能是原有瓶颈被时序放大;若加固候选出现新调用或锁等待,则继续核对构建身份、符号和策略范围;若峰值只出现在首轮,检查热身与编译状态;若随温度持续恶化,检查设备降频。每个假设都需要可以反向验证的实验,不能用删除最差样本替代。

修复后仍用原场景和门禁回归,并增加能覆盖根因的负向用例。例如把大对象解码移出主线程后,测试要确认解码不再落在帧关键区;调整 JNI 临界区后,确认锁等待事件消失;修正列表 diff 后,确认相同数据量下布局工作受控。性能变化和功能正确性分开断言,避免为了变快跳过数据加载或动画步骤。

长帧 trace 的责任分类
观察区间候选原因补充证据禁止结论
主线程布局测量视图层级或数据变化同页面 trace 与节点数保护代码必然慢
主线程同步 I/O文件、数据库或网络调用路径和数据状态加快渲染线程即可
JNI 或锁等待Native 计算或竞争阶段时长与 ownerJNI 一定是根因
GC 区间分配突增或内存压力分配与堆证据一次 GC 代表长期回归
RenderThread 或 GPU绘制与驱动负载渲染阶段和设备主线程优化足够
首轮类加载冷路径或编译状态热身迭代对照稳态也会同样发生

设备矩阵与线上信号承担不同的放行职责

instrumented test 和 Macrobenchmark 可在独立测试进程、受控脚本与设备条件下执行关键路径,适合形成发布前可重复基线。设备矩阵应覆盖目标 API、ABI、刷新率和性能档位,逐组合保存实际型号、OS 和执行回执。云真机目录会变化,因此计划与实际分配都要记录。某个矩阵单元缺失时,只能标记未覆盖,不能用相邻设备通过代替。

Android vitals 可以观察线上版本、设备与性能相关质量信号,帮助判断回归范围,但样本受安装来源、用户同意和统计口径限制,也无法替代同条件候选基线。若真实数据尚未接入,报告明确写未接入,不编造帧比例、趋势或排名。线上信号异常时回到对应版本和设备复现;线上稳定也不证明所有关键路径已覆盖。

安全发布应保留来源、构建、验证和变更证据,这与 NIST SSDF 的方向一致。对帧回归,最小证据包包含两份候选摘要、场景定义、测试代码摘要、设备回执、原始样本、剔除记录、统计器版本、项目预算和最终判定。框架不会替项目定义阈值,也不证明任何产品有性能改善。发布后输入或策略变化,旧回执不得继续代表新候选。

  • Macrobenchmark 或设备测试在独立进程执行固定页面脚本
  • 每个矩阵单元记录实际设备、OS、ABI、刷新率和温控状态
  • 原始逐帧样本、无效迭代和剔除原因全部保留
  • 线上信号只用于观察范围,不替代同条件候选比较
  • 候选、测试、统计器、预算与判定写入不可变回执
  • 任何未覆盖设备或路径明确标记未知,不外推通过

用帧样本比较器拒绝条件不一致的结论

下面的 Python 示例读取原始包和加固包两个 JSON 文件。每份文件包含 candidateSha256、deviceModel、osVersion、abi、routeId、refreshHz、warmupPolicy、fixtureDigest、jankThresholdMs 和 framesMs。脚本先验证摘要、元数据和正数样本,再要求除候选摘要外的条件完全相同,随后计算中位数、高分位、最大值与异常帧比例,并根据命令行传入的项目差异预算决定是否返回非零状态。

代码不设置通用帧阈值,jankThresholdMs 由场景文件按项目预算提供且两侧必须一致;p95DeltaBudgetMs 与 jankRatioDeltaBudget 由执行门禁传入。生产系统还要按迭代分层、保留 trace 引用和统计不确定性,不能把所有帧当成独立样本。示例不读取用户数据、业务页面内容、密钥或服务器,只处理脱敏度量和稳定场景标识。

准备页面流畅度评估时,可整理实际原始 APK 与加固 APK、源码与策略摘要、页面脚本、脱敏数据夹具、设备矩阵、刷新率、热身规则、项目预算和已有 trace,再从御盾中央平台提交申请。没有同条件基线和当前候选回执时,只能报告观察值,不能写性能无影响、已有提升、全部设备流畅或加固导致卡顿。

  • 两份样本分别绑定不同且有效的候选 SHA 摘要
  • 设备、OS、ABI、页面、刷新率、热身与夹具元数据完全一致
  • 每个帧时长为有限正数且保留原始输入文件
  • 异常帧阈值与相对差异预算由项目预先冻结
  • 条件不一致、输入缺失和超预算均返回非零状态
  • 比较结果只用于门禁,具体根因仍由同次 trace 证明
加固前后帧分布与可比条件门禁
from pathlib import Path
import json
import math
import re
import statistics
import sys

if len(sys.argv) != 5:
    raise SystemExit("usage: frame_gate.py original.json hardened.json p95_budget_ms ratio_budget")
paths = [Path(sys.argv[1]), Path(sys.argv[2])]
if any(not path.is_file() for path in paths):
    raise SystemExit("both sample files are required")
try:
    p95_budget = float(sys.argv[3])
    ratio_budget = float(sys.argv[4])
except ValueError as exc:
    raise SystemExit("budgets must be numeric") from exc
if p95_budget < 0 or ratio_budget < 0:
    raise SystemExit("budgets must not be negative")

sha_pattern = re.compile(r"^[a-f0-9]{64}$")
metadata = ["deviceModel", "osVersion", "abi", "routeId", "refreshHz", "warmupPolicy", "fixtureDigest", "jankThresholdMs"]

def load_sample(path):
    try:
        sample = json.loads(path.read_text(encoding="utf-8"))
    except (OSError, json.JSONDecodeError) as exc:
        raise SystemExit(f"cannot read {path.name}") from exc
    if not isinstance(sample, dict):
        raise SystemExit(f"{path.name}: expected an object")
    digest = str(sample.get("candidateSha256", ""))
    if not sha_pattern.fullmatch(digest):
        raise SystemExit(f"{path.name}: invalid candidate digest")
    if any(str(sample.get(field, "")).strip() == "" for field in metadata):
        raise SystemExit(f"{path.name}: missing comparison metadata")
    frames = sample.get("framesMs")
    if not isinstance(frames, list) or len(frames) < 2:
        raise SystemExit(f"{path.name}: insufficient frame samples")
    try:
        values = [float(value) for value in frames]
    except (TypeError, ValueError) as exc:
        raise SystemExit(f"{path.name}: invalid frame value") from exc
    if any(not math.isfinite(value) or value <= 0 for value in values):
        raise SystemExit(f"{path.name}: frame values must be positive")
    return sample, digest, sorted(values)

original, original_sha, left = load_sample(paths[0])
hardened, hardened_sha, right = load_sample(paths[1])
if original_sha == hardened_sha:
    raise SystemExit("candidate digests must differ")
for field in metadata:
    if original[field] != hardened[field]:
        raise SystemExit(f"incomparable metadata: {field}")
threshold = float(original["jankThresholdMs"])
if not math.isfinite(threshold) or threshold <= 0:
    raise SystemExit("jank threshold must be positive")

def summary(values):
    index = max(0, math.ceil(len(values) * 0.95) - 1)
    jank_ratio = sum(value > threshold for value in values) / len(values)
    return {"count": len(values), "median": statistics.median(values), "p95": values[index], "max": values[-1], "jankRatio": jank_ratio}

base = summary(left)
current = summary(right)
print(json.dumps({"original": base, "hardened": current}, sort_keys=True))
p95_delta = current["p95"] - base["p95"]
ratio_delta = current["jankRatio"] - base["jankRatio"]
raise SystemExit(1 if p95_delta > p95_budget or ratio_delta > ratio_budget else 0)

事实依据与适用边界

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

本文判断事实或工程依据适用限制
启动与关键交互路径的性能比较应在独立测试进程和可重复设备条件下执行多次迭代。Android Macrobenchmark 描述跨进程测量启动、滚动、动画等关键用例的基准框架。框架不提供加固前后的预设结论,脚本、设备、样本和项目预算仍需自行验证。
线上质量信号可帮助观察版本、设备和性能问题范围。Android vitals 描述崩溃、ANR、启动和设备分布等线上信号。Play 样本受安装来源、用户同意与统计口径限制,且当前项目数据未接入本文。
启动性能需要区分 TTID 与 TTFD,并记录冷温热状态和测量条件。Android app startup time 定义启动状态、首帧和报告完全绘制的测量语义。单次启动值不能代表分布,也不能替代稳态页面帧表现或加固因果证据。
依赖真实 Android 组件、渲染和系统 API 的交互路径应在设备端验证。Android instrumented tests 说明在 Android 设备或模拟设备上运行依赖平台的测试。单一设备通过不能代表全部 API、ABI、刷新率、GPU 驱动和厂商调度。
云测试设备目录、稳定性和容量会变化,回执必须记录实际型号与 OS。Test Lab available devices 描述可用 Android 设备、状态与选择信息。平台可分配设备不代表覆盖目标用户的全部厂商特性和业务路径。
安全发布应保留来源、构建、验证和变更证据。NIST SP 800-218 SSDF 给出组织级安全软件开发和供应链风险管理实践。SSDF 不定义帧阈值,也不证明某个 App 加固产品具有性能改善或兼容能力。
帧回归应比较分位数、异常帧比例和连续长帧,而不是只看平均值。工程判断:长尾与连续异常帧更能保留少量可感知停顿,平均值会稀释极端样本。具体统计量与阈值仍需匹配场景、刷新率、样本量和项目体验预算。
没有同条件原始基线时,只能报告加固候选的观察分布和绝对预算结果。工程判断:候选、设备、数据或热身状态不同会混入无法分离的性能变量。观察值不能写成加固提高、降低、无影响或对全部设备稳定的因果结论。

工程常见问题

加固后页面感觉卡,先看平均帧率可以吗?

不够。平均值会稀释少量严重长帧,应固定场景后保留逐帧样本,比较中位数、高分位、异常帧比例、连续异常帧和对应 trace。

启动变慢是否等同于页面滚动掉帧?

不等同。启动要区分 TTID、TTFD 和冷温热状态,滚动掉帧关注稳定页面交互区间;两套指标和诊断路径应分开。

原始包和加固包在不同型号手机上测能比较吗?

不能形成候选差分。设备、OS、ABI、刷新率、温控和驱动都会影响帧时长;条件不同只能分别报告观察值。

帧卡顿是否有所有 App 通用的毫秒阈值?

不应脱离刷新率、页面类型和体验目标套用统一值。项目应在测试前冻结异常帧和相对差异预算,并同时看绝对体验线与基线变化。

加固包高分位变差能否直接认定保护导致?

不能。先确认候选和环境可比、差异稳定,再用同次 trace 区分布局、I/O、锁、JNI、GC、渲染和降频,并通过最小变量反向验证。

一台设备通过流畅度门禁是否可以宣布全面兼容?

不可以。结果只覆盖该候选、设备、刷新率、页面路径和夹具,还需按真实 API、ABI、性能档位与厂商范围执行矩阵。

想用自己的 App 验证?

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

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