先看结论与判断条件
- 内存验收必须绑定候选摘要、设备型号、系统版本、ABI、场景步骤、采样周期和冷暖状态,单个峰值没有独立结论。
- PSS、RSS、Java heap、Native heap 和图形分配回答不同问题,应保留同一时间线,不能用其中一个数替代整体压力。
- 冷启动、资源密集核心路径、前后台切换和长时运行要分开执行,避免不同生命周期的峰值被混在一个平均值中。
- OOM 判断要结合 ApplicationExitInfo、Java 异常、Native tombstone、系统日志与测试时间窗,进程消失本身不是充分归因。
- 加固前后比较必须保持设备、数据集、操作序列和采样工具一致,并用多次运行观察分布,而不是挑选一次最好或最差结果。
- 线上 Android vitals 适合补充真实用户信号,但受安装来源、用户同意和统计口径限制,不能代替发布前设备回归。
先把验收问题改写成可复核条件
App 加固可能改变类加载、代码布局、Native 库装载和初始化时序,这些变化可能影响不同内存区域,但不能从机制描述直接推断候选一定增加或减少内存。验收问题应写成:在同一设备、相同数据、相同步骤和相同采样条件下,加固候选是否出现可重复的峰值、稳定区间或异常退出差异。
测试对象首先绑定 APK 摘要、versionCode、applicationId、ABI、签名和加固配置摘要。基线包也要保留相同字段,并确认除待比较处理外没有混入业务代码、资源、依赖或编译参数变化。若两个包不是可辩护的对照,结果只能作为各自观测,不能归因为加固。
通过标准不能只写不 OOM。应为每个场景定义成功动作、采样起止点、需要观察的内存分区、允许的退出类型、证据缺口和复跑条件。没有预先定义门槛时,测试人员容易在结果出来后临时选择指标,既无法复现,也无法解释为什么同一峰值在不同批次得到不同判断。
| 条件 | 最低记录 | 变化影响 | 处理 |
|---|---|---|---|
| 候选身份 | APK 摘要与配置摘要 | 测试对象改变 | 新建批次 |
| 设备 | 型号、系统、RAM 与 ABI | 压力和回收策略变化 | 禁止直接合并 |
| 应用状态 | 新装、冷启或已有缓存 | 分配与加载路径变化 | 分组统计 |
| 业务数据 | 脱敏数据集版本 | 对象数量和资源体积变化 | 固定输入 |
| 操作步骤 | 动作、次数和节奏 | 峰值窗口变化 | 脚本化或记录 |
| 采样工具 | 版本、命令和周期 | 口径与扰动变化 | 重新建立基线 |
四类场景必须拆开测量
冷启动场景观察进程创建、Application 和 Provider 初始化、首个 Activity、首帧以及核心资源装载。测试前要明确是否强制停止、是否清理数据、页面缓存是否存在和目标进程是否残留。启动后的瞬时峰值可能很高,却很快回落;因此既要记录峰值,也要记录可交互后稳定窗口。
核心路径选择真实内存压力最大的业务动作,例如大图解码、复杂列表、地图、音视频或大量 Native 计算,但必须使用公开安全且固定的数据集。每个动作设置开始、阶段完成和资源释放标记,以便把峰值关联到具体操作。只在首页空闲采样,无法覆盖用户真正会触发的分配。
前后台切换与长时运行回答另外两个问题。切后台可能触发生命周期释放、缓存收缩或系统回收;反复恢复可能暴露监听器、线程和 Native 对象泄漏。长时场景则观察稳定区间是否持续上移。两者都要规定循环动作和停止条件,不能无限运行直到失败再把时间差当作结论。
| 场景 | 起点 | 主要观测 | 完成条件 |
|---|---|---|---|
| 冷启动 | 进程不存在 | 初始化峰值与稳定回落 | 核心界面可交互 |
| 核心路径 | 固定业务状态 | 阶段峰值和释放 | 动作结果完成 |
| 前后台切换 | 前台稳定 | 缓存收缩与恢复增量 | 规定循环结束 |
| 长时运行 | 路径预热完成 | 稳定区间趋势 | 达到预定时长或门禁 |
| 低内存干预 | 受控设备状态 | 回调和进程退出 | 状态与记录齐全 |
| 异常路径 | 可重复错误输入 | 分配失败和资源释放 | 错误语义明确 |
PSS、RSS 与各堆必须同时解释
进程总内存不是单一数字。RSS 统计驻留页但共享页会在多个进程重复出现,PSS 按比例分摊共享页,更适合做进程间汇总;Java heap 反映托管对象,Native heap 反映本地分配,图形缓冲和映射文件又可能不完全进入前两者。报告应保留指标名称与单位,避免把不同口径拼成一条曲线。
峰值用于发现短时容量压力,稳定区间用于观察路径结束后保留多少,增长斜率用于判断重复动作是否持续积累。单次垃圾回收、页面换入或后台系统活动会造成抖动,所以同一场景要重复运行,并保存原始时间序列。只有汇总表而没有原始样本,无法判断峰值是真实路径事件还是采样噪声。
多进程应用还要同时记录主进程和远程进程。只看主进程可能把分配转移误判成优化,只把各进程 RSS 相加又会重复计算共享页。工程判断应优先比较逐进程 PSS 与全应用汇总 PSS,同时展示 Java、Native 和图形分区,任何不可采集区域都要标记为空缺而不是填零。
| 指标 | 适合回答 | 主要限制 | 报告方式 |
|---|---|---|---|
| PSS | 共享页分摊后的进程压力 | 仍是采样时点 | 逐进程及全应用 |
| RSS | 实际驻留页规模 | 跨进程相加会重复共享页 | 单进程辅助 |
| Java heap | 托管对象分配与回收 | 不覆盖 Native 和图形 | 与 GC 事件对齐 |
| Native heap | 本地 allocator 压力 | 不覆盖全部 mmap | 与 SO 路径对齐 |
| Graphics | 纹理和缓冲相关压力 | 设备与渲染后端差异 | 单列并说明工具 |
| 系统可用内存 | 设备整体压力背景 | 受其他进程影响 | 作为环境证据 |
采样设计要能区分峰值、稳定值和泄漏嫌疑
每条采样至少包含候选标识、run_id、scenario、process、timestamp、PSS、RSS、Java、Native、图形和阶段标记。时间戳应使用同一时钟域,阶段标记由测试动作产生。若工具返回未知或不支持,应保存 null 和原因,不能把缺失字段写为零,因为零会被误读成真实观测。
峰值比较要使用相同采样周期。周期太长可能错过瞬时峰,太短又可能放大测量扰动;选择依据应在测试计划中固定。稳定窗口应从业务动作明确完成后开始,并排除已知初始化阶段。长时趋势可以比较固定窗口的中位数,但不能用任意两点连线声称泄漏。
内存增长只是泄漏嫌疑,不是根因证明。缓存设计、图片池、JIT、文件映射和系统页面回收都可能让曲线在一段时间内上升。发现稳定窗口持续抬高后,再使用 heap dump、allocation profile、Native 分配跟踪或代码审查定位,同时保留工具带来的行为扰动。
| 字段 | 类型 | 必需性 | 校验 |
|---|---|---|---|
| run_id | 字符串 | 必需 | 同一次执行唯一 |
| scenario | 字符串 | 必需 | 来自批准场景 |
| timestamp_ms | 整数 | 必需 | 同 run 单调递增 |
| pss_kb | 非负数 | 必需 | 缺失则整组阻塞 |
| native_heap_kb | 非负数或 null | 建议 | 不支持时写缺口 |
| exit_reason | 字符串或 null | 条件必需 | 进程结束时关联 |
OOM 结论必须落到退出原因和时间窗
进程从采样列表中消失,可能是正常退出、用户操作、系统资源回收、崩溃、ANR 或测试基础设施中断。ApplicationExitInfo 能提供退出原因与相关诊断信息,部分系统版本还可返回 ANR trace 或 Native tombstone。采集时必须按包、进程、用户、候选和测试时间窗筛选。
Java OutOfMemoryError、Native 分配失败和系统低内存杀进程的证据形态不同。报告应保留异常类型、退出 reason、进程名、重要性、时间、最后场景阶段和可用诊断附件。没有退出记录时不能自动写无 OOM,因为记录可能受系统版本、保留数量、权限或采集时机影响。
同一退出记录也不能独立证明加固是根因。需要在同设备同路径基线中比较,并排除业务数据变化、其他进程压力、平台差异和测试工具故障。若只在加固候选稳定复现,可以升级为回归嫌疑;直到定位具体分配或生命周期差异前,仍应避免写确定因果。
| 信号 | 可能含义 | 所需补充 | 可下结论 |
|---|---|---|---|
| Java OutOfMemoryError | 托管或相关分配失败 | 堆栈与同构建映射 | 该路径出现异常 |
| Native tombstone | Native 崩溃 | 符号、ABI 与 build ID | 该进程发生 Native 退出 |
| 系统低内存 reason | 资源压力下被回收 | importance 与设备状态 | 系统记录的退出类型 |
| ANR trace | 进程未及时响应 | 主线程与锁时间线 | 发生 ANR 记录 |
| 进程列表消失 | 多种可能 | ApplicationExitInfo 与日志 | 不能单独归因 |
| 采样中断 | 工具或设备异常 | 基础设施日志 | 本次证据不完整 |
用聚合脚本保留原始样本和证据缺口
下面的 Python 脚本读取脱敏 JSON Lines 采样,每行包含 run_id、scenario、timestamp_ms、pss_kb 和可选 exit_reason。它按运行和场景分组,要求时间戳递增、PSS 非负、每组至少三个样本,输出峰值、全程中位数、末段中位数、退出原因和缺口。脚本只聚合真实输入,不生成阈值或通过结论。
末段中位数取每组最后一部分样本,用来提供稳定区间线索,不等于泄漏判断。正式分析应先按测试动作确定稳定窗口,而不是机械使用尾部样本;示例保留此简化,是为了展示如何把假设明确写入代码和输出。任何样本过少或时间倒序都会失败,避免残缺数据被悄悄汇总。
输入不得包含用户标识、业务内容、令牌或内部地址。run_id 应为随机测试标识,scenario 使用公开分类,进程详细信息可以在受控回执中另行关联。输出要与原始文件摘要、聚合脚本版本和执行时间绑定,防止后续换样本却继续引用旧报告。
import json
import statistics
import sys
from collections import defaultdict
from pathlib import Path
if len(sys.argv) != 2:
raise SystemExit('usage: aggregate_memory_samples.py samples.jsonl')
input_path = Path(sys.argv[1])
if not input_path.is_file():
raise SystemExit('sample file is missing')
groups = defaultdict(list)
for line_number, line in enumerate(input_path.read_text(encoding='utf-8').splitlines(), 1):
if not line.strip():
continue
try:
row = json.loads(line)
except json.JSONDecodeError as exc:
raise SystemExit(f'invalid JSON at line {line_number}: {exc}')
for field in ('run_id', 'scenario', 'timestamp_ms', 'pss_kb'):
if field not in row:
raise SystemExit(f'missing {field} at line {line_number}')
if not isinstance(row['timestamp_ms'], int) or row['timestamp_ms'] < 0:
raise SystemExit(f'invalid timestamp at line {line_number}')
if not isinstance(row['pss_kb'], (int, float)) or row['pss_kb'] < 0:
raise SystemExit(f'invalid PSS at line {line_number}')
groups[(str(row['run_id']), str(row['scenario']))].append(row)
if not groups:
raise SystemExit('no samples found')
results = []
for (run_id, scenario), rows in sorted(groups.items()):
rows.sort(key=lambda item: item['timestamp_ms'])
if len(rows) < 3:
raise SystemExit(f'too few samples for {run_id}/{scenario}')
timestamps = [item['timestamp_ms'] for item in rows]
if len(set(timestamps)) != len(timestamps):
raise SystemExit(f'duplicate timestamps for {run_id}/{scenario}')
values = [float(item['pss_kb']) for item in rows]
tail = values[-max(3, len(values) // 3):]
exits = sorted({str(item['exit_reason']) for item in rows if item.get('exit_reason')})
results.append({'run_id': run_id, 'scenario': scenario, 'samples': len(rows), 'peak_pss_kb': max(values), 'median_pss_kb': statistics.median(values), 'tail_median_pss_kb': statistics.median(tail), 'exit_reasons': exits, 'gaps': [] if exits else ['exit-reason-not-observed']})
print(json.dumps(results, ensure_ascii=False, indent=2))Native 内存问题要绑定符号和实际构建
加固后若出现 Native 崩溃、分配失败或异常增长,调试材料必须与候选中实际 SO 的 ABI、build ID 和文件摘要一致。Debug Android native code 说明 Native 调试与 tombstone 还原依赖正确符号和构建产物。拿未加固库或另一个 ABI 的符号解析地址,可能给出看似合理但错误的函数位置。
符号文件用于定位,不应随公开报告披露私有实现。受控证据包可以保存 tombstone、未剥离符号摘要、映射文件、加固前后 SO 摘要和解析工具版本。若加固改变代码布局,必须使用与输出候选匹配的符号映射链,不能直接继承输入 SO 的地址关系。
Native heap 只是本地分配的一部分,mmap、共享库页面、图形缓冲和驱动资源可能另有统计入口。调查时要把分配栈与场景阶段对齐,并确认释放是否延迟到生命周期结束。单个 Native 数字上升不能证明泄漏,更不能证明攻击阻断或防护强度。
设备实验室与线上 vitals 互为补充
Android instrumented tests 可以在真实运行时驱动固定业务步骤,适合把场景、阶段标记和断言自动化。设备实验室能扩展型号与系统覆盖,但设备目录、稳定性和容量会变化,计划必须记录实际分配到的型号、OS、ABI 和执行时间。平台显示可用不代表它覆盖目标用户的全部厂商特性。
Android vitals 提供崩溃、ANR、启动和设备分布等线上质量信号,可用于观察发布后的真实环境趋势。其样本受安装来源、用户同意和统计口径限制,也无法自动构造严格的加固前后对照。因此线上变化应作为调查线索,并与版本、设备和退出记录关联。
发布前实验回答受控路径能否在已测矩阵运行,线上 vitals 回答部分真实用户是否出现质量信号。两边都不能扩张到未覆盖设备。若实验室通过但线上某设备族异常,应补充代表设备和场景;若线上暂无线索,也不能删掉发布前压力测试。
| 证据层 | 优势 | 限制 | 使用方式 |
|---|---|---|---|
| 本地代表设备 | 可控和可深度诊断 | 型号覆盖少 | 建立可重复基线 |
| 设备实验室 | 扩展型号与系统 | 容量和设备目录变化 | 记录实际设备回执 |
| instrumented test | 步骤和断言自动化 | 用例外路径不覆盖 | 固定场景重复运行 |
| ApplicationExitInfo | 系统退出原因 | 受时间窗和保留限制 | 绑定候选和进程 |
| Android vitals | 真实用户质量信号 | 样本与统计口径限制 | 观察发布趋势 |
| Native 诊断 | 定位本地崩溃与分配 | 依赖匹配符号 | 受控根因分析 |
发布门禁要保留原始数据和结论边界
NIST SSDF 强调保留来源、构建、验证和变更证据。内存门禁应保存候选与基线身份、场景定义、设备回执、原始采样摘要、聚合脚本、退出记录、诊断附件和审批结论。汇总数字必须能回溯到原始时间序列,修订阈值或排除样本也要记录原因。
发现回归嫌疑时先判断是否可重复,再按 Java、Native、图形、映射、进程拆分和生命周期定位。一个场景阻塞不应被其他场景平均掉;同样,某个设备失败也不能直接外推全部设备。发布决定应列出通过、失败、阻塞和未覆盖项,而不是只给一个绿色总状态。
准备内存与 OOM 评估时,应提交候选和基线摘要、加固配置差异、场景脚本、设备与系统、原始采样、退出记录、Native 符号身份和证据缺口。申请入口由御盾中央平台统一承接。没有同候选的真实数据时,不写无 OOM、内存已优化、兼容通过或性能收益。
事实依据与适用边界
以下内容区分官方事实、本文工程判断和不能外推的范围,避免把设计建议写成未经验证的产品结论。
| 本文判断 | 事实或工程依据 | 适用限制 |
|---|---|---|
| ApplicationExitInfo 能提供进程退出原因和相关诊断信息。 | Android ApplicationExitInfo 说明退出原因、ANR trace 与新版本 Native tombstone 能力。 | 退出记录必须与同一候选、进程、用户路径和测试时间窗关联。 |
| Android vitals 提供崩溃、ANR、启动和设备分布等线上质量信号。 | Android vitals 说明 Play 侧质量指标及其观察入口。 | 线上样本受安装来源、用户同意和统计口径限制,不能替代受控内存回归。 |
| 依赖真实 Android 运行时的内存场景应通过设备端测试执行。 | Android instrumented tests 说明测试可在真实或模拟 Android 环境中运行。 | 单一设备、ABI 和 API 版本通过不能代表完整支持矩阵。 |
| 设备目录、稳定性和容量会变化,计划应记录实际型号与系统回执。 | Test Lab available devices 说明 Android 测试设备目录和可用性信息。 | 设备可用不代表覆盖目标业务全部厂商特性和资源压力。 |
| Native 崩溃与 tombstone 还原依赖匹配的符号、架构和构建产物。 | Debug Android native code 说明 Native 调试和符号材料的使用关系。 | 调试材料可用不等于内存回归已定位或生产兼容通过。 |
| 安全发布需要保留来源、构建、验证和变更证据。 | NIST SP 800-218 SSDF 将安全开发和供应链证据纳入组织实践。 | 组织级框架不定义单个候选的内存阈值、加固能力和验收结果。 |
| 内存峰值必须与稳定区间、场景阶段和退出原因共同判读。 | 工程判断:单个时点无法区分瞬时初始化、缓存保留、持续增长和采样噪声。 | 组合观测仍是回归证据,不自动证明具体分配点或加固因果。 |
| 加固前后比较必须保持设备、数据、步骤和采样口径一致。 | 工程判断:比较条件变化会破坏差异解释,产生不可复核的归因。 | 条件一致只能提高可比性,仍需重复运行和根因证据。 |
工程常见问题
只看一次 PSS 峰值能判断加固引入内存回归吗?
不能。应在同设备同路径重复采样,同时比较峰值、稳定窗口、各内存分区和退出原因,并保留原始时间序列。
进程突然消失是否就是 OOM?
不是。需要结合 ApplicationExitInfo、Java 异常、Native tombstone、系统日志、时间窗和测试基础设施状态区分退出类型。
Java heap 没有增长,为什么总内存仍可能上升?
Native heap、mmap、共享库页面、图形缓冲和其他分配不一定体现在 Java heap。应同时观察 PSS、Native、图形和进程拆分。
内存曲线持续上升就能确定存在泄漏吗?
不能直接确定。缓存、JIT、文件映射和延迟释放都可能造成阶段性增长;先固定稳定窗口重复复现,再用堆和分配工具定位。
设备实验室全部通过是否代表所有用户设备安全?
不代表。目录和容量变化,代表设备也不能覆盖全部厂商特性;应记录实际矩阵,并用线上质量信号补充未知环境。
申请加固后内存与 OOM 评估要准备什么?
准备候选和基线摘要、加固差异、场景脚本、设备回执、原始采样、退出记录、Native 符号身份和缺口,再通过御盾中央平台提交。