PackageCacher 缓存
本文承接 Manifest 解析入口、meta-data 解析 和 IntentFilter 解析。前文关注 XML 如何生成 ParsedPackage;本文继续追踪这个对象如何被缓存到磁盘,以及下一次扫描时什么条件允许它直接复用。
这里的“缓存”只指 PackageParser2 使用的包解析结果缓存,不是 PackageManager 返回 ApplicationInfo 的内存缓存,也不是应用的 code cache。Android 17 的实现把缓存设计成 best-effort 加速层:缓存读失败、版本不一致或 feature flag 变化都会退回完整解析,不改变安装语义。
1. 缓存边界
1.1 调用链
缓存发生在 parser 层:命中时直接返回 ParsedPackage;未命中时走完整解析,解析成功后再写缓存。后续扫描、组件 resolver 和权限处理消费的是同一种 ParsedPackage,不需要知道它来自 XML 还是 Parcel。
1.2 接口契约
源码文件:frameworks/base/core/java/com/android/internal/pm/parsing/IPackageCacher.java
public interface IPackageCacher {
/**
* Returns the cached parse result for packageFile and flags,
* or null if no cached result exists.
*/
ParsedPackage getCachedResult(File packageFile, int flags);
/** Caches the parse result for packageFile with flags. */
void cacheResult(File packageFile, int flags, ParsedPackage parsed);
}接口只有读和写两个操作,没有“必须命中”或“写入失败抛异常”的契约。null 是正常的未命中结果;调用方必须保留完整解析路径。
2. Parser 入口
2.1 构造时注入
源码文件:frameworks/base/services/core/java/com/android/server/pm/PackageManagerService.java
mCacheDir = PackageManagerServiceUtils.preparePackageParserCache(
mIsEngBuild, mIsUserDebugBuild, mIncrementalVersion);
new PackageParser2(pm.mSeparateProcesses, i.getDisplayMetrics(),
new PackageCacher(pm.mCacheDir, pm.mPackageParserCallback),
pm.mPackageParserCallback);PMS 只给“扫描用”的 PackageParser2 注入 PackageCacher;准备包或其他解析器可以传入 null,从而完全不读写解析缓存。缓存目录由 preparePackageParserCache() 创建和版本化,PackageCacher 自身只使用传入目录。
2.2 读命中、写回退
源码文件:frameworks/base/core/java/com/android/internal/pm/parsing/PackageParser2.java
public ParsedPackage parsePackage(File packageFile, int flags, boolean useCaches)
throws PackageParserException {
var files = packageFile.listFiles();
if (ArrayUtils.size(files) == 1 && files[0].isDirectory()) {
packageFile = files[0];
}
if (useCaches && mCacher != null) {
ParsedPackage parsed = mCacher.getCachedResult(packageFile, flags);
if (parsed != null) {
return parsed;
}
}
ParseInput input = mSharedResult.get().reset();
ParseResult<ParsingPackage> result = mParsingUtils.parsePackage(input, packageFile, flags);
if (result.isError()) {
throw new PackageParserException(result.getErrorCode(), result.getErrorMessage(),
result.getException());
}
ParsedPackage parsed = (ParsedPackage) result.getResult().hideAsParsed();
if (mCacher != null) {
mCacher.cacheResult(packageFile, flags, parsed);
}
return parsed;
}这里有一个常被忽略的时机差异:useCaches=false 只跳过读取,不阻止写回;只要 mCacher 非 null,完整解析成功后仍会调用 cacheResult()。因此测试“禁用缓存”时若要避免磁盘副作用,应同时不注入 cacher 或清理目录。
3. 缓存键
3.1 缓存键组成
源码文件:frameworks/base/services/core/java/com/android/server/pm/parsing/PackageCacher.java
private String getCacheKey(File packageFile, int flags) {
String name = packageFile.getName();
String absPath = packageFile.getAbsolutePath();
StringBuilder sb = new StringBuilder(name);
sb.append('-');
sb.append(flags);
if (absPath != null) {
sb.append('-');
sb.append(absPath.hashCode());
}
return sb.toString();
}缓存键同时包含 APK 文件名、解析 flags 和绝对路径 hash。相同文件名位于 /system 与 /data 时,路径 hash 使两份结果分开;不同 flags 也不会共用同一缓存文件。
3.2 APEX 的稳定名称
if (absPath.startsWith("/dev/block/dm-")) {
try {
name = IoUtils.readFileAsString("/sys/block/" + name + "/dm/name").trim();
// The device name represents the file. No need to append the hash of the full path.
absPath = null;
} catch (IOException e) {
Slog.w("Error while reading device name of " + name, e);
}
}APEX 可能挂载在每次启动都变化的 dm-NN 路径上。代码改用 device-mapper 的稳定名称,并省略路径 hash;读取失败则保留原 name/path 逻辑并记录 warning。这个分支解释了为什么缓存键不能简单写成 packageFile.getName()。
4. Parcel 格式
4.1 写入入口
public static byte[] toCacheEntryStatic(ParsedPackage pkg) {
final Parcel p = Parcel.obtain();
final PackageParserCacheHelper.WriteHelper helper =
new PackageParserCacheHelper.WriteHelper(p);
((PackageImpl) pkg).writeToParcel(p, 0 /* flags */);
helper.finishAndUninstall();
byte[] serialized = p.marshall();
p.recycle();
return serialized;
}缓存对象必须是 PackageImpl,写入的是其 Parcelable 字段,而不是重新保存 XML。WriteHelper 在 PackageImpl 写完后追加字符串池,并卸载自身的 Parcel helper,最后才 marshall() 成字节数组。
4.2 字符串池
源码文件:frameworks/base/core/java/android/content/pm/PackageParserCacheHelper.java
public WriteHelper(Parcel p) {
mParcel = p;
mStartPos = p.dataPosition();
mParcel.writeInt(0); // later replaced with pool position
mParcel.setReadWriteHelper(this);
}
public void writeString(Parcel p, String s) {
final Integer cur = mIndexes.get(s);
if (cur != null) {
p.writeInt(cur);
} else {
final int index = mStrings.size();
mIndexes.put(s, index);
mStrings.add(s);
p.writeInt(index);
}
}
public void finishAndUninstall() {
mParcel.setReadWriteHelper(null);
final int poolPosition = mParcel.dataPosition();
mParcel.writeStringList(mStrings);
mParcel.setDataPosition(mStartPos);
mParcel.writeInt(poolPosition);
mParcel.setDataPosition(mParcel.dataSize());
}Parcel 主体只保存字符串索引,字符串列表统一放在末尾;第一个整数保存字符串池的起始位置。这样包名、类名、权限和 action 等重复文本不会在每个字段中完整复制。
4.3 读取入口
private static ParsedPackage fromCacheEntryStatic(byte[] bytes,
@Nullable ParsingPackageUtils.Callback callback) {
final Parcel p = Parcel.obtain();
p.unmarshall(bytes, 0, bytes.length);
p.setDataPosition(0);
final PackageParserCacheHelper.ReadHelper helper =
new PackageParserCacheHelper.ReadHelper(p);
helper.startAndInstall();
ParsedPackage pkg = new PackageImpl(p, callback);
p.recycle();
sCachedPackageReadCount.incrementAndGet();
return pkg;
}读取时先根据池位置加载字符串列表,再构造 PackageImpl(Parcel, callback)。callback 仍由当前 parser 提供,说明缓存保存的是包数据快照,不会把旧进程中的 callback 实例序列化进去。
5. 新鲜度判断
5.1 mtime 比较
源码文件:frameworks/base/services/core/java/com/android/server/pm/parsing/PackageCacher.java
private static boolean isCacheFileUpToDate(File packageFile, File cacheFile) {
try {
final StructStat pkg = Os.stat(packageFile.getAbsolutePath());
final StructStat cache = Os.stat(cacheFile.getAbsolutePath());
return pkg.st_mtime < cache.st_mtime;
} catch (ErrnoException ee) {
if (ee.errno != OsConstants.ENOENT) {
Slog.w("Error while stating package cache : ", ee);
}
return false;
}
}只有缓存文件的修改时间严格晚于包文件才算新鲜。任一 stat 异常都返回 false,随后走完整解析;不存在缓存文件是最常见的未命中情形。
5.2 APEX backing
对 /apex 挂载点,挂载文件的 mtime 可能恒为 0,源码会通过 ApexManager.getInstance().getBackingApexFile(packageFile) 改用实际 APEX 文件进行比较。找不到 backing file 时记录 warning,仍以失败的新鲜度结果回退解析。
6. 命中校验
6.1 路径回读
public ParsedPackage getCachedResult(File packageFile, int flags) {
final String cacheKey = getCacheKey(packageFile, flags);
final File cacheFile = new File(mCacheDir, cacheKey);
try {
if (!isCacheFileUpToDate(packageFile, cacheFile)) {
return null;
}
final byte[] bytes = IoUtils.readFileAsByteArray(cacheFile.getAbsolutePath());
final ParsedPackage parsed = fromCacheEntry(bytes);
if (!packageFile.getAbsolutePath().equals(parsed.getPath())) {
// Don't use this cache if the path doesn't match.
return null;
}mtime 通过后还要比较缓存对象内部的 parsed.getPath()。这一步防止路径 hash 冲突或缓存文件被错误替换时误用结果。
6.2 Flag 校验
if (!android.content.pm.Flags.includeFeatureFlagsInPackageCacher()) {
return parsed;
}
final Map<String, Boolean> featureFlagState =
((PackageImpl) parsed).getFeatureFlagState();
for (var entry : featureFlagState.entrySet()) {
final String flagPackageAndName = entry.getKey();
if (!Objects.equals(AconfigFlags.getInstance().getFlagValue(flagPackageAndName),
entry.getValue())) {
Slog.i(TAG, "Feature flag " + flagPackageAndName + " changed for package "
+ packageFile + "; cached result is invalid");
return null;
}
}
return parsed;当 includeFeatureFlagsInPackageCacher() 开启时,缓存中的 feature flag 快照必须与当前 AconfigFlags 值逐项相等;任一变化都会使缓存失效。解析代码之所以保留 flag 状态,是为了让 Manifest 中受 flag 控制的字段变化能够触发重新解析。
6.3 读取异常的自愈
} catch (Throwable e) {
Slog.w(TAG, "Error reading package cache: ", e);
// Delete the bad entry so the next parse can regenerate it.
cacheFile.delete();
return null;
}读取、反序列化或校验出现任意异常,代码删除坏缓存并返回 null。下一次调用会重新解析并尝试写入新文件;缓存损坏不会把安装流程变成永久失败。
7. 写入与清理
7.1 best-effort 写入
源码文件:frameworks/base/services/core/java/com/android/server/pm/parsing/PackageCacher.java
public void cacheResult(File packageFile, int flags, ParsedPackage parsed) {
try {
final String cacheKey = getCacheKey(packageFile, flags);
final File cacheFile = new File(mCacheDir, cacheKey);
if (cacheFile.exists() && !cacheFile.delete()) {
Slog.e(TAG, "Unable to delete cache file: " + cacheFile);
}
final byte[] cacheEntry = toCacheEntry(parsed);
try (FileOutputStream fos = new FileOutputStream(cacheFile)) {
fos.write(cacheEntry);
} catch (IOException ioe) {
Slog.w(TAG, "Error writing cache entry.", ioe);
cacheFile.delete();
}
} catch (Throwable e) {
Slog.w(TAG, "Error saving package cache.", e);
}
}写入前删除旧文件,写入失败删除半成品;外层再捕获所有异常。缓存目录不可写、Parcel 序列化失败或磁盘空间不足都不会覆盖已经成功的解析结果。
7.2 按包清理
public void cleanCachedResult(@NonNull File packageFile) {
final String packageName = packageFile.getName();
final File[] files = FileUtils.listFilesOrEmpty(mCacheDir,
(dir, name) -> name.startsWith(packageName));
for (File file : files) {
if (!file.delete()) {
Slog.e(TAG, "Unable to clean cache file: " + file);
}
}
}清理按 APK 文件名的前缀删除同一包的多个 flags/path 变体。它不是按单一完整 key 删除,因此适合包路径变化、升级或卸载时清空旧组合。
8. 版本目录
8.1 目录准备
源码文件:frameworks/base/services/core/java/com/android/server/pm/PackageManagerServiceUtils.java
// The base directory for the package parser cache lives under /data/system/.
final File cacheBaseDir = Environment.getPackageCacheDirectory();
if (!FileUtils.createDir(cacheBaseDir)) {
return null;
}
// Return the versioned package cache directory.
File cacheDir = FileUtils.createDir(cacheBaseDir, cacheName);
if (cacheDir == null) {
// Something went wrong. Attempt to delete everything and return.
return null;
}
return cacheDir;PMS 使用系统目录下的版本化子目录隔离缓存。版本名由 build 类型和增量版本等输入决定;准备失败时返回 null,后续 parser 因没有 cacher 而退回普通解析。
8.2 版本变化的效果
版本目录变化不会逐个比较旧缓存,而是让新 mCacheDir 指向另一目录。旧目录是否被删除由准备逻辑处理;从 parser 视角看,旧文件不可见,等价于全量未命中。
9. 状态与消费者
9.1 四种状态
“写入缓存失败”不会回到错误状态,而是沿“返回解析包”继续;“读取缓存失败”则先删除坏文件,再沿“完整解析”恢复。
9.2 下游不区分来源
命中和回退最终都返回 ParsedPackage。扫描阶段的 ComponentResolver.addAllComponents、权限注册、metadata 派生和 split 处理都消费同一接口,因此缓存层没有改变这些下游状态机,只改变对象构造成本。
10. 测试与诊断
10.1 测试输入和断言
| 场景 | 输入 | 断言 |
|---|---|---|
| 首次读取 | 无 cache file | getCachedResult() 返回 null |
| 新鲜缓存 | cache mtime 晚于 package mtime | 返回反序列化包 |
| 过期缓存 | package mtime 更新 | 返回 null,随后完整解析 |
| 路径不符 | 修改缓存中的 ParsedPackage.path | 返回 null |
| flag 改变 | 缓存快照与 AConfig 当前值不同 | 返回 null |
| Parcel 损坏 | 随机字节或截断文件 | 删除文件并返回 null |
| 写入失败 | 目录不可写或 I/O 异常 | 解析仍成功,半成品被删除 |
| 多 flags | 同一 APK 使用不同 flags | 生成不同 key |
useCaches=false | 注入 cacher 后强制完整解析 | 不读取,但成功后仍写回 |
测试应同时断言“返回值”和“文件副作用”:仅检查返回对象无法证明坏文件是否删除,也无法发现 useCaches=false 仍会写回这一行为。
10.2 现场排查顺序
- 确认 PMS 注入的 parser 是否持有
PackageCacher,以及mCacheDir是否为 null。 - 根据文件名、flags 和路径 hash 重算 cache key。
- 比较 APK/backing APEX 与缓存文件的 mtime。
- 查看缓存反序列化后的
ParsedPackage.getPath()。 - 若启用 feature flag 校验,比较缓存
featureFlagState与当前 AConfig 值。 - 读取失败时检查日志中的删除动作,确认下一次解析是否重新生成文件。
11. 源码路线
建议按以下顺序阅读:
PackageParser2.parsePackage():理解读取开关、完整解析和写回时机。IPackageCacher:确认缓存层的 nullable/best-effort 契约。PackageCacher.getCacheKey():分析 flags、路径和 APEX 稳定命名。PackageCacher.isCacheFileUpToDate():分析 mtime 与 backing APEX。PackageCacher.getCachedResult():路径和 feature flag 二次校验。PackageParserCacheHelper:理解 Parcel 字符串池布局。PackageCacher.cacheResult()与cleanCachedResult():观察写入失败和清理策略。PackageManagerServiceUtils.preparePackageParserCache():确认目录版本化和初始化失败回退。
12. 设计收束
PackageCacher 的可靠性来自“缓存不拥有真相”:APK 文件、解析 flags 和当前 feature flag 才是有效性的依据,磁盘文件只是可丢弃的快照。命中必须经过时间、路径和 flag 校验;读异常删除后重建;写异常不影响当前解析;目录版本变化通过换目录自然隔离。
因此读代码时应始终把两条路径并列起来:
缓存命中:key -> mtime -> Parcel -> path/flag 校验 -> ParsedPackage
缓存未命中:完整 XML 解析 -> hideAsParsed -> best-effort 写缓存 -> ParsedPackage两条路径在 ParsedPackage 汇合,之后才进入扫描和组件注册。理解这个汇合点,才能正确判断缓存命中率、解析耗时和安装行为之间的关系。
