先看结论与判断条件
- 原子提交的目标是让包管理器看到一个完整新状态或保持原状态,而不是按文件写入顺序逐个暴露 split。
- 一次会话中的 base 与各 split 必须属于同一包名、兼容版本和签名身份,split 名称也要唯一且与目标设备选择相符。
- 必需集合应从受控 APK set 清单和设备规格派生,不能让客户端根据下载目录里现有文件自行猜测。
- 写入完成不等于安装完成,调用方必须处理提交后的异步状态、用户确认、失败码、会话放弃和重试幂等。
- bundletool 可以重现设备相关的 APK 选择并支持本地测试,但调试签名或本地生成结果不能冒充商店发布回执。
- 验收必须覆盖缺少 base、漏 split、重复 split、版本混搭、证书不一致、中断写入和失败重试,且证据绑定同一 APK set。
把 split 集合理解成一个应用状态
Android App Bundle 会按模块和配置组织内容,设备最终安装的是从 AAB 派生的一组 APK。base 提供应用的基础清单、代码与资源,配置 split 可以承载 ABI、语言或屏幕密度内容,feature split 则承载相应功能模块。运行时看到的是这些文件共同组成的包状态,不是多个互不相关的小应用。
如果把 split 当作普通文件逐个安装,过程中就可能出现 base 已更新而 ABI split 仍旧、资源 split 缺失或 feature 与 base 版本不一致。即使文件都能单独解析,组合后的类、资源和 Native 依赖也可能无法闭合。原子安装会话的工程意义,是在状态可见之前完成集合与身份校验,失败时保持既有可用状态或明确拒绝新安装。
原子不代表每个内部操作同时发生,也不意味着存储介质永不出错。它描述的是包管理器对应用状态的提交语义:调用方写入全部会话文件、关闭流并提交,随后根据最终状态判断整体成功或失败。业务不能在收到最终成功前假设新版本已经可用,更不能把“某个 split 写入完成”当作升级完成。
| 成员 | 主要内容 | 为什么属于同一状态 | 缺失或错配后果 |
|---|---|---|---|
| base APK | 基础 Manifest、代码和公共资源 | 定义应用与大多数 split 的共同根 | 安装无法建立或引用基础缺失 |
| ABI split | 目标架构的 Native 库 | 与设备 ABI 和 base 调用路径绑定 | Native 装载失败或功能不可用 |
| 语言 split | 本地化字符串与资源 | 资源表与 base 版本共同生成 | 界面回退、资源查找异常或文案缺失 |
| 密度 split | 目标屏幕密度资源 | 由设备配置选择并引用同一资源表 | 资源质量下降或查找不完整 |
| feature split | 独立功能代码与资源 | 依赖 base 与可能的配置 split | 入口存在但类或资源无法加载 |
| feature 配置 split | 特定 feature 的 ABI、语言或密度内容 | 只对对应 feature 与设备条件有效 | 功能模块局部缺失或装载失败 |
先确定目标设备的必需 split 集合
安装器不能根据下载目录里有哪些文件来定义“完整”。完整集合应来自受控 APK set 元数据、目标设备规格和发布策略。设备 ABI、语言、密度、系统版本及条件模块会影响选择结果。客户端接收清单时应验证清单来源、版本、到期时间和整体摘要,再按 required 标记准备会话,不允许把下载失败的条目静默改成可选。
Android App Bundle format 描述 base、feature、配置和资产模块如何组成 AAB;AAB 本身不是直接安装到设备的最终文件。测试人员需要把模块视图转换成设备交付视图,列出目标设备实际需要的 APK 名称、split 名称、包名、versionCode、签名证书和文件摘要。相同 AAB 对不同设备产生的必需集合可能不同,因此清单必须绑定 device_spec_digest。
可选 feature 也要区分此次会话是否承诺安装。如果发布策略要求首次安装即包含某 feature,它就是这次会话的必需项;若业务采用后续按需交付,则不应混入当前基础安装会话的必需集合。本文关注设备安装会话原子性,不展开动态功能下载状态机,但两类流程都需要明确集合所有者和唯一提交边界。
| 字段 | 绑定对象 | 检查方式 | 失败处理 |
|---|---|---|---|
| set_id | 同一批派生 APK 与元数据 | 与发布记录和整体摘要比对 | 拒绝未知或过期集合 |
| device_spec_digest | ABI、语言、密度等选择条件 | 重算或读取受控设备规格摘要 | 重新生成目标集合 |
| package_name | 会话中所有 APK 的应用身份 | 逐条目提取并比较 | 任何不一致都拒绝提交 |
| version_code | base 与 split 的版本身份 | 逐条目提取并比较 | 禁止跨版本拼装 |
| certificate_sha256 | 全部 APK 的签名身份 | 官方签名工具验证 | 证书不一致时停止安装 |
| required_splits | 此次会话承诺的完整集合 | 与实际写入名称做集合比较 | 缺失或多余均需解释 |
| artifact_sha256 | 每个 APK 的精确字节 | 写入前流式重算 | 摘要失败则放弃会话 |
在提交前核对包名、版本、签名与 split 名称
PackageInstaller API 对会话中的 APK 施加同包安装约束。工程门禁应在写入前自行核对,避免把错误留到提交阶段才发现。base 必须恰好一个;每个 split 名称必须唯一;所有成员的 packageName 和 versionCode 必须与 base 一致;签名证书及连续性也要满足平台规则。清单字段与实际 APK 提取结果必须双向对应。
只比较文件名不够。文件名可以被任意修改,真正的 splitName、包名、版本和签名要从 APK 内部与验证报告读取。会话写入名称可以使用稳定、安全的内部命名,但不能作为身份来源。若 central manifest 声称某摘要对应 ABI split,而文件内部却属于语言 split 或其他包,必须在进入 PackageInstaller 前拒绝。
多签名者、证书轮换和不同签名方案需要按目标 Android 范围评估。AOSP app signing 说明 Android 使用应用签名建立更新身份,不同方案覆盖不同平台版本与文件区域。本文不展开证书轮换细节,只要求会话验证结果来自同一发布策略;签名验证成功只证明完整性与签名者身份,不代表 split 业务内容安全。
- base APK 在必需集合中恰好出现一次
- 每个 split 名称唯一且与清单角色一致
- 所有成员 packageName 与 base 完全一致
- 所有成员 versionCode 属于同一候选版本
- 签名证书和连续性满足目标平台策略
- 每个文件 SHA-256 与受控 APK set 清单一致
- 任何未知、多余或缺失成员都阻止提交
正确实现会话写入、提交与最终状态
典型流程是创建 PackageInstaller Session,打开会话,为每个 APK 创建写入流,完整复制并按 API 要求同步,关闭所有流后再提交。写入顺序不应改变集合语义,但日志要记录条目名称、预期长度、实际长度和摘要。任何文件写入失败、长度不符或摘要不符,都应放弃会话,不能跳过该文件继续提交。
commit 发起的是安装请求,不等于同步返回最终成功。流程可能需要用户操作,也可能在验证、空间、策略或包约束阶段失败。调用方必须通过可靠状态接收通道处理 pending user action、success 和 failure,并将最终结果绑定 session_id 与 set_id。界面显示“正在安装”不能更新业务版本基线,只有最终成功回执才能推进。
失败回滚也要有明确边界。系统拒绝新会话时,调用方应确认已安装包仍保持原版本,并清理未提交或已放弃会话的临时状态。若最终状态不确定,例如进程在提交后退出,应先查询包版本和会话结果,不能立即用新 session 重复提交。幂等策略以目标 packageName、versionCode 与 set_id 为键,区分安全重试和重复安装。
| 阶段 | 调用方动作 | 成功依据 | 失败或中断处理 |
|---|---|---|---|
| 创建会话 | 固定包、版本、大小和安装模式 | 获得唯一 session_id | 创建失败不写任何 APK |
| 写入成员 | 逐文件写入、校验长度与摘要 | 全部 required 成员完成 | 任一失败即放弃会话 |
| 关闭与同步 | 关闭所有流并完成持久化要求 | 没有未完成写入句柄 | 不要带半写文件提交 |
| 提交请求 | 使用可靠状态接收通道 commit | 收到提交已接受的流程状态 | 不把方法返回当安装成功 |
| 用户确认 | 展示系统要求的确认流程 | 用户与系统继续安装 | 拒绝时记录稳定失败类别 |
| 最终结果 | 处理 success 或 failure | success 且包状态符合目标 | 失败时保持旧状态并记录原因 |
| 不确定恢复 | 查询 session 与已安装包身份 | 确定旧状态或新状态唯一存在 | 避免盲目重复创建会话 |
更新现有应用时保护版本与签名连续性
Split 更新不仅要让新集合内部一致,还要与设备已安装状态连续。How Android app updates work 说明更新需要保持 applicationId,并满足兼容签名或轮换证明及可接受 versionCode。会话创建前应读取已安装包的包名、版本和签名基线,与目标 base 和 split 比较,明确这是正常升级、同版本修复、受控轮换还是不允许的降级。
更新模式还决定旧 split 如何处理。调用方不能简单认为“本次没写入的旧 split 会保持正确”,也不能假设所有未列出的成员都会自动删除。应根据 PackageInstaller 模式、目标平台行为和完整目标集合设计,测试升级前后的实际 split 列表。发布验收只记录观察到的结果,不把某个设备的行为推广为所有系统版本。
业务状态迁移需要在安装原子性之外处理。包管理器成功切换 APK 集合,不代表数据库迁移、缓存格式或动态资源已经正确升级。应用首次启动应识别新 versionCode,执行可回滚或幂等迁移,并在失败时给出安全状态。本文的安装会话门禁不能替代应用级升级测试,只能保证交付集合没有明显混搭。
| 比较项 | 已安装基线 | 目标会话 | 拒绝或重验条件 |
|---|---|---|---|
| packageName | 设备当前应用身份 | base 与全部 split 的包身份 | 任一不一致都不是原应用更新 |
| versionCode | 当前已安装版本 | 目标集合统一版本 | 不满足更新策略或出现混搭 |
| 签名身份 | 当前证书与连续性状态 | 目标成员验证结果 | 不兼容签名或轮换证据缺失 |
| split 集合 | 升级前实际成员 | 目标设备应有的完整成员 | 残留、缺失或未知 split |
| ABI 与资源配置 | 设备与旧交付选择 | 当前设备规格派生选择 | 设备条件变化未被重新计算 |
| 应用数据版本 | 数据库与缓存 schema | 新版本迁移要求 | 安装通过但迁移未验证 |
| 发布证据 | 旧候选摘要和回执 | 新 set_id 与各 APK 摘要 | 证据引用不同候选 |
用 bundletool 固定派生集合与测试边界
bundletool 可以从 AAB 构建 APK set、按设备规格选择 APK 并安装到连接设备。它适合在提交商店前重现 base 与配置 split 的组合,生成受控清单供会话检查。每次运行应保存 bundletool 版本、输入 AAB 摘要、签名参数类别、设备规格摘要和输出 APK set 摘要,避免下次无法解释成员变化。
未显式提供发布签名时,本地生成可能使用调试签名。调试签名 APK set 可以验证 split 选择和会话逻辑,却不能作为正式更新候选或签名连续性结论。Build and test Android App Bundles 提供本地测试路径,但商店最终处理、签名和交付仍需测试轨道或下载样本回执确认。证据等级必须清楚标记。
设备规格抽样应覆盖产品支持的主要 ABI、语言、密度和条件 feature 边界。每个样本生成独立 required_splits 清单并执行缺失、重复和混搭负向用例。一个 universal APK 安装成功不能证明 split 会话正确,因为它绕开了部分设备选择与多文件提交条件。原子性结论必须来自真实多 APK 会话。
- 保存输入 AAB、输出 APK set 与设备规格摘要
- 记录 bundletool 版本和签名参数类别
- 调试签名结果只登记为本地支持证据
- 每种代表设备生成独立 required_splits 清单
- 至少一个用例使用真实多 APK 会话而非 universal APK
- 本地派生结果不冒充商店实际交付
- 测试轨道样本重新核对包名、版本、证书和成员集合
用只读脚本校验会话包含全部必需 split
下面的 Python 示例读取一份公开安全的 APK set 会话清单。清单列出 set_id、包名、versionCode、证书摘要、一个 base 和若干 split,并给出 session_files。脚本检查必需成员与实际会话集合完全一致,拒绝重复名称、缺少 base、包名版本证书混搭和摘要格式错误。任一输入派生条件失败都会非零退出。
示例只检查清单内部一致性,没有打开真实 APK,也没有验证签名或文件摘要。生产流水线需要对每个实际文件重算 SHA-256,并用官方工具提取包名、versionCode、splitName 与证书,再把观察值写入受签清单。若清单和文件同时被替换,这段代码仍可能通过,因此它不能充当完整供应链或签名验证。
代码不创建、写入或提交 PackageInstaller 会话,也不包含设备命令。它适合作为提交前的一道只读门禁,把“文件是否齐全”从安装 API 调用中分离出来。真正的原子性仍要通过同一 session 的写入、commit 最终状态和设备包状态回执证明,且所有证据绑定相同 set_id。
#!/usr/bin/env python3
import json
import re
import sys
from pathlib import Path
SHA256_PATTERN = re.compile(r"^[a-f0-9]{64}$")
PACKAGE_PATTERN = re.compile(r"^[A-Za-z][A-Za-z0-9_]*(?:[.][A-Za-z][A-Za-z0-9_]*)+$")
NAME_PATTERN = re.compile(r"^[A-Za-z0-9_.-]+[.]apk$")
def fail(message, code):
print(message, file=sys.stderr)
raise SystemExit(code)
def require_text(record, field):
value = record.get(field)
if not isinstance(value, str) or not value.strip():
fail("missing field: " + field, 3)
return value.strip()
def validate(manifest):
package_name = require_text(manifest, "package_name")
if not PACKAGE_PATTERN.fullmatch(package_name):
fail("invalid package_name", 4)
version_code = manifest.get("version_code")
if not isinstance(version_code, int) or version_code < 1:
fail("invalid version_code", 5)
certificate = require_text(manifest, "certificate_sha256")
if not SHA256_PATTERN.fullmatch(certificate):
fail("invalid certificate digest", 6)
artifacts = manifest.get("artifacts")
if not isinstance(artifacts, list) or not artifacts:
fail("artifacts must be a nonempty list", 7)
names = []
required = set()
base_count = 0
for item in artifacts:
if not isinstance(item, dict):
fail("artifact must be an object", 8)
name = require_text(item, "name")
if not NAME_PATTERN.fullmatch(name):
fail("unsafe artifact name", 9)
names.append(name)
if item.get("required") is True:
required.add(name)
if item.get("type") == "base":
base_count += 1
if item.get("required") is not True:
fail("base must be required", 10)
if item.get("package_name") != package_name:
fail("package mismatch: " + name, 11)
if item.get("version_code") != version_code:
fail("version mismatch: " + name, 12)
if item.get("certificate_sha256") != certificate:
fail("certificate mismatch: " + name, 13)
digest = item.get("sha256")
if not isinstance(digest, str) or not SHA256_PATTERN.fullmatch(digest):
fail("invalid artifact digest: " + name, 14)
if len(names) != len(set(names)):
fail("duplicate artifact name", 15)
if base_count != 1:
fail("exactly one base APK is required", 16)
session_files = manifest.get("session_files")
if not isinstance(session_files, list) or not all(isinstance(x, str) for x in session_files):
fail("invalid session_files", 17)
if len(session_files) != len(set(session_files)):
fail("duplicate session file", 18)
actual = set(session_files)
if actual != required:
missing = sorted(required - actual)
extra = sorted(actual - required)
print("missing=" + ",".join(missing), file=sys.stderr)
print("extra=" + ",".join(extra), file=sys.stderr)
raise SystemExit(19)
def main():
if len(sys.argv) != 2:
fail("Usage: split_session_check.py APK_SET_JSON", 2)
input_path = Path(sys.argv[1])
if not input_path.is_file():
fail("APK set manifest not found", 2)
try:
manifest = json.loads(input_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
fail("cannot read manifest: " + str(exc), 2)
if not isinstance(manifest, dict):
fail("manifest must be an object", 2)
validate(manifest)
print("split session set accepted")
if __name__ == "__main__":
main()用同一会话的证据矩阵完成验收
验收应先固定 AAB 摘要、APK set 摘要、设备规格、set_id 和各成员摘要,再创建安装会话。写入记录包含 session_id、成员名、预期与实际长度、摘要结果和同步状态;提交记录包含用户确认、最终状态和稳定失败类别;设备记录包含安装前后 packageName、versionCode、签名身份与实际 split 列表。只有引用完全一致才能闭环。
负向用例要覆盖集合和生命周期。分别移除 base、遗漏 ABI split、重复名称、混入其他版本、替换证书、写入中断、提交后进程退出和用户拒绝,确认系统不会把局部完成当成功,也不会在状态未知时盲目重复安装。测试样本可以使用公开安全的空白应用,不需要暴露真实包名、证书或生产设备。
当前没有具体 AAB、APK set、签名候选、PackageInstaller 状态回执和设备 split 清单,因此本文只能给出方法与代码,不能宣称某次安装已经原子完成或某个发布候选兼容。项目落地时应对每个代表设备保存真实回执;AAB、签名、设备规格、目标版本或 split 策略变化后重新生成清单并重验。
| 证据项 | 最低内容 | 合格条件 | 不能推出 |
|---|---|---|---|
| APK set 身份 | AAB、APK set、设备规格和 set_id 摘要 | 全部成员来自同一派生过程 | 线上商店交付已确认 |
| 成员清单 | base、splitName、包名、版本、证书和摘要 | 必需集合无缺失、重复或混搭 | 应用业务功能正常 |
| 会话写入 | session_id、长度、摘要和同步状态 | 所有 required 成员完整写入 | 安装已经最终成功 |
| 提交结果 | 用户确认与最终 success 或 failure | 最终状态与 set_id 可追溯 | 应用数据迁移正确 |
| 设备包状态 | 安装前后版本、证书与 split 列表 | 只存在原状态或完整目标状态 | 其他设备同样通过 |
| 更新连续性 | 已安装基线与目标元组 | 包名、版本与签名满足更新条件 | 业务防重放策略完成 |
| 负向用例 | 缺失、重复、混搭、中断和拒绝 | 每种错误在预期层失败 | 完整攻击阻断已经验证 |
| 变化触发器 | AAB、签名、设备规格和 split 策略 | 任一变化都重建清单并重验 | 旧回执可迁移 |
事实依据与适用边界
以下内容区分官方事实、本文工程判断和不能外推的范围,避免把设计建议写成未经验证的产品结论。
| 本文判断 | 事实或工程依据 | 适用限制 |
|---|---|---|
| PackageInstaller 提供基于会话的包安装流程,split 安装要求会话成员满足同一包的身份和版本签名约束。 | Android PackageInstaller | API 约束不证明商店服务器为所有设备生成的 split 都已被抽样,也不证明业务迁移成功。 |
| AAB 由 base、feature、配置与资产模块组成,设备安装的是按配置派生的 APK 集。 | Android App Bundle format | AAB 容器本身不是设备直接安装的最终 APK,也不能单独证明目标集合完整。 |
| Android App Bundle 可以通过本地工具和测试流程构建 APK set、选择目标设备产物并执行验证。 | Build and test Android App Bundles | 本地生成和安装不能完全替代商店处理、测试轨道交付和生产设备回执。 |
| bundletool 可以从 AAB 生成、检查和部署按设备配置选择的 APK set。 | bundletool | 未显式提供发布签名信息时的本地产物不能冒充正式发布候选。 |
| Android 使用应用签名建立应用身份与更新关系,不同签名方案覆盖不同平台版本和文件区域。 | AOSP app signing | 签名有效只证明完整性与签名者身份,不证明 split 内容、业务逻辑或设备兼容安全。 |
| Android 应用更新需要保持应用标识,并满足兼容签名或轮换证明与可接受 versionCode 等条件。 | How Android app updates work | 平台更新条件不等于业务层最低版本、撤销和防重放策略。 |
| 工程判断:base 与全部必需 split 应在同一会话中完成集合、包名、版本、签名和摘要核对后整体提交。 | 工程判断:基于应用状态一致性、会话提交和失败恢复原则 | 具体会话参数、用户确认和升级模式需要按目标 Android 版本与发布场景验证。 |
| 当前没有具体 AAB、APK set、会话状态和设备回执,不能断言任何 split 安装已经原子完成。 | 项目证据尚未接入 | 文章仅提供公开安全的设计、检查代码和证据矩阵,不包含客户案例、性能数字或实测通过结论。 |
工程常见问题
为什么不能先安装 base APK,再逐个补装配置 split?
base 与配置 split 共同构成目标设备上的一个应用状态。逐个暴露会产生版本、资源或 Native 库暂时混搭的风险,也让失败恢复难以判断。应在同一会话中写入全部必需成员,核对身份后提交,并以最终状态作为成功依据。
PackageInstaller 的 commit 返回后是否代表安装成功?
不代表同步成功。提交后可能需要用户确认,也可能在包验证、空间、策略或身份检查中失败。调用方必须处理可靠状态回执,并在最终 success 后核对设备包版本和 split 列表,不能用方法返回或界面进度替代。
怎样判断一次会话需要哪些 split?
从受控 APK set 元数据和目标设备规格派生,结合 ABI、语言、密度、系统版本及此次承诺安装的 feature 形成 required_splits。客户端只验证并执行清单,不能根据下载目录现有文件猜测完整集合。
使用 universal APK 测试是否可以证明 split 会话正确?
不能。universal APK 可以提供快速功能支持证据,但绕开了设备相关 split 选择和多文件会话提交。原子性验收至少需要一个真实 base 加配置 split 的会话,并覆盖漏项、重复、混搭和中断等负向用例。
本地 bundletool 安装通过是否等于商店交付通过?
不等于。本地产物可能使用调试签名,设备选择、商店签名和交付处理也可能不同。应把本地结果标记为支持证据,再通过测试轨道或下载样本核对实际证书、版本、成员集合和设备运行。
提交后进程退出,是否应该立即创建新会话重试?
不应盲目重试。先查询原 session 状态和设备已安装包身份,确认旧状态或目标状态是否已经成立。以 packageName、versionCode 和 set_id 做幂等关联;只有原会话明确失败或不可恢复时才创建新会话。