Android Emulator 的快照功能,保存测试状态

Android Emulator 的快照功能,保存测试状态

Android Emulator 的快照功能,保存测试状态


Android Emulator 的快照功能,保存测试状态


一个被反复打断的下午


去年三月份,我在调试一个应用内购买的流程问题。测试环境需要 Google Play 结算库 6.0 的完整配置,包括许可证测试账号、测试信用卡、以及一个特定的地区设置。每次冷启动 Emulator 后,我都要重新登录 Google 账号、接受服务条款、等待 Play Store 同步、再进入应用的购买流程。整个准备过程大概七分钟,而实际的 bug 复现只需要三十秒。


那天下午我被产品经理叫去确认了三次需求,Emulator 因为内存压力被系统杀掉了两次。等我终于定位到问题——BillingClientConnectionState 在特定重试逻辑下没有正确重置——我已经花了四个多小时,其中至少两个小时在重复搭建测试环境。


这个下午之后,我开始认真研究 Android Emulator 的快照功能。不是那种"知道有这么个东西"的浅层了解,而是把它当作日常开发工具链的一部分来配置和打磨。一年多用下来,它确实改变了我的工作流,但也有一些坑是官方文档不会主动告诉你的。


快照到底是什么:不是简单的截图


很多人第一次接触 Emulator 快照时,会把它和"保存屏幕状态"混淆。实际上,Android Emulator 基于 QEMU 的虚拟机快照机制保存的是整个虚拟机的完整状态:CPU 寄存器、内存内容、磁盘镜像、甚至网络连接的状态。这意味着当你恢复快照时,得到的不是一个"看起来一样"的界面,而是虚拟机在某个精确时间点的完整冻结状态。


这个技术细节直接影响使用方式。在 Android Studio 的 Device Manager 中,创建快照的入口在 Emulator 侧边栏的 Snapshots 标签页,或者通过命令行 emulator -avd Pixel_7_API_34 -snapshot name -snapstorage ~/.android/avd/Pixel_7_API_34.avd/snapshots。快照文件默认存储在 AVD 目录下的 snapshots/ 文件夹中,每个快照是一个独立的子目录,包含内存镜像(ram.bin)和元数据(snapshot_metadata.pb)。


快照和 AVD 的"多用户数据分区"功能是不同层面的东西。后者只是为同一个系统镜像创建独立的 /data 分区,每次启动都是冷启动,只是用户数据彼此隔离。快照则是在运行时状态上的冻结,恢复后是继续执行,不是重新启动。这个区别在调试后台服务、定时任务、或者需要保持特定 Activity 栈的场景下非常关键。


我的核心使用场景


我目前维护的快照大概分为四类,每一类对应不同的开发痛点。


第一类是"环境基线"快照。我为每个主要项目维护一个配置好的 Emulator 实例:Google Play 服务已更新、测试账号已登录、必要的权限已授予、开发者选项已开启。以我正在做的一个健康类应用为例,它需要 Health Connect 权限和精确的步数传感器模拟。基线快照在 HealthConnect 权限授予界面之前一步冻结,这样我每次可以从权限弹窗开始测试,而不是从桌面重新找应用图标。


第二类是"缺陷现场"快照。这个用法需要一点纪律:复现 bug 之后,不要急着修复,先保存快照。我在团队内部推广这个做法时,遇到过阻力——"保存快照太占空间了"。实际测试下来,一个典型的 bug 现场快照大约在 800MB 到 1.5GB 之间,取决于应用内存占用。作为对比,一个同事花两小时重新复现 bug 的人力成本,换算成存储费用根本不在一个数量级。我们现在的做法是:确认 bug 可复现后,立即保存快照,命名格式 issue_1427_crash_on_rotation_api34,然后上传到团队的共享存储。后续修复时,任何人都可以精确回到这个状态。


第三类是"长流程中间状态"。应用内购买的测试是一个典型例子,从启动到进入支付页面需要点击七八次,涉及网络请求和服务器状态同步。我在支付确认前的最后一个界面保存快照,这样每次测试不同的支付异常场景(网络超时、用户取消、卡片被拒)时,直接从快照恢复,跳过前面的冗长流程。这个做法在测试 Deeplink 跳转时同样有效:保存一个应用在后台、特定 Activity 在栈顶的状态,然后测试各种 Deeplink 触发方式。


第四类是"版本兼容性矩阵"。我们的应用最低支持 API 26,目标 API 34。我为每个关键 API 级别维护一个快照,系统镜像已更新到该 API 级别的最新安全补丁版本。测试特定 API 行为差异时,直接切换快照,而不是重新创建 AVD。这个做法在 Android 14 引入的 LocaleManager 行为变更上帮了大忙:API 33 和 34 的处理逻辑差异,通过两个快照的快速切换,对比起来非常直观。


配置细节:让快照真正可用


默认配置下,快照功能有一些限制需要手动解除。


在 Android Studio 的 Settings → Emulator 中,"Emulator data backup" 选项默认可能是关闭的,这会导致快照功能不可用。需要确保勾选 "Save quick boot state on exit for fast startup",这个选项控制的是自动保存的"快速启动"快照,和手动创建的命名快照是同一套机制。


更关键的配置在 AVD 的 config.ini 文件中。每个 AVD 目录下有这个文件,其中 disk.dataPartition.size 决定数据分区大小。如果快照频繁创建失败,或者恢复后数据丢失,常见原因是数据分区已满。我通常设置为 disk.dataPartition.size=8G,对于现代开发机来说这个开销可以接受。另一个容易忽略的参数是 hw.ramSize,快照保存的是内存镜像,如果 AVD 配置了 4GB RAM,每个快照的内存部分就接近 4GB。在存储紧张时,可以适当降低 AVD 内存配置,但要确保应用仍能正常运行。


快照的命名策略直接影响后续使用效率。我采用 场景_版本_日期_备注 的格式,例如 baseline_playbilling_6.0_20240315_signed_in。避免使用默认的 "quickBoot" 名称,因为快速启动机制会自动覆盖这个快照。如果手动创建的快照也叫这个名字,会被自动保存机制意外覆盖。


命令行操作在某些场景下更高效。emulator -avd Pixel_7_API_34 -snapshot-list 列出所有快照,emulator -avd Pixel_7_API_34 -snapshot issue_1427 -snapstorage /custom/path 从指定路径加载快照。这在 CI 环境中特别有用:我们在 GitHub Actions 的 self-hosted runner 上预置了一组快照,测试任务启动时直接加载对应快照,而不是从头配置 Emulator。


存储与性能的真实开销


快照的空间占用是劝退很多人的因素。我实际测量过几个典型场景:


一个刚创建的空 AVD(API 34,Google Play 镜像),无应用运行时的快照约 420MB。安装我们的应用(APK 约 85MB),登录账号并浏览几个主要页面后,快照增长到 1.2GB。在应用内存占用较高时(比如加载了大量图片),快照曾达到 2.8GB。


这个空间占用主要来自内存镜像的压缩前数据。QEMU 的快照实现会先保存完整内存状态,然后进行压缩。压缩率取决于内存内容的可压缩性——大量零值或重复模式的内存区域压缩效果很好,但实际的 Android 系统内存分布通常比较杂乱。


我的管理策略是:基线快照长期保留,缺陷快照保留到对应 issue 关闭后一个月,中间状态快照通常当天删除。配合 snapshot-delete 命令定期清理,避免存储无限增长。在 SSD 上,一个 1.5GB 快照的加载时间约 8-15 秒,机械硬盘上会慢到 40 秒以上,基本不可用。这是我把快照目录通过符号链接放到 SSD 分区的原因。


性能方面,快照恢复后的 Emulator 运行速度和正常启动后没有区别。因为恢复的是完整内存状态,不需要重新执行启动流程。但有一个细节:如果快照保存时 Emulator 正在执行 CPU 密集型任务,恢复后可能会短暂卡顿,因为 CPU 状态被精确还原,包括缓存内容。这个影响通常在一两秒内消失。


与测试框架的集成


快照功能在自动化测试中的价值,被官方文档严重低估了。


Espresso 测试的一个经典痛点是测试隔离与执行速度的矛盾。每个测试用例完全独立需要重新安装应用、清除数据,执行缓慢;共享状态又可能导致测试间干扰。我的做法是在测试套件开始前,从基线快照启动 Emulator,然后每个测试类内部管理自己的状态重置。对于需要特定前置状态的测试,直接从对应快照启动,而不是在测试中逐步构建状态。


具体实现上,我们通过 Gradle 任务封装了 Emulator 管理。gradle-emulator-plugin 这个第三方插件(GitHub: novoda/gradle-android-command-plugin,Apache 2.0 协议)提供了基础能力,但我们做了不少定制。核心逻辑是在 build.gradle 中定义快照任务:


task startFromSnapshot(type: Exec) {
    commandLine 'emulator', '-avd', 'Pixel_7_API_34', 
                '-snapshot', 'baseline_payment_test',
                '-no-snapshot-save'  // 测试结束后不覆盖快照
}

-no-snapshot-save 参数很关键:它告诉 Emulator 在退出时不要自动保存状态覆盖现有快照。这样基线快照不会被测试过程中的状态修改破坏。


在 CI 环境中,我们使用 Firebase Test Lab 的物理设备做最终验证,但开发阶段的快速迭代完全依赖本地 Emulator + 快照。一个典型的回归测试流程:从 baseline_api33baseline_api34 两个快照并行启动 Emulator,分别运行对应的测试套件,总时间从原来的 25 分钟降到 12 分钟。这个时间提升主要来自跳过了两次完整的系统启动和应用初始化。


踩过的坑:快照不是银弹


第一个坑是 Google Play 服务的自动更新。我保存了一个基线快照,其中 Play 商店版本是 37.5。两周后恢复这个快照,启动后 Play 服务立即开始后台更新,弹通知、占 CPU、改状态,完全破坏了我保存快照时的环境假设。现在的做法是:基线快照创建后,在系统设置中关闭 Play 商店的自动更新,然后重新保存快照。这个状态冻结在快照里,后续恢复时不会自动更新。


第二个坑是时间敏感逻辑。快照保存的是虚拟机的内部时钟状态,恢复后时钟继续从冻结点走动。但如果你的应用依赖网络时间校验(比如 JWT token 的有效期),或者使用了 System.currentTimeMillis() 做缓存过期判断,恢复后的快照可能处于"时间错乱"状态。我遇到过保存快照时 token 还有 10 分钟有效期,恢复后过了两小时才测试,token 已过期但应用状态显示"已登录"。解决方案是在关键测试前,通过 adb shell date 手动同步时间,或者使用 android.os.SystemClock 的测试替代。


第三个坑是网络连接状态的残留。快照保存时如果正在进行网络请求,恢复后这个请求的状态可能被冻结在半途中。一个具体案例:Retrofit 的某个请求刚发出去,响应还没回来,此时保存快照。恢复后,底层的 Socket 连接状态不一致,可能导致 OkHttp 的连接池抛出 IOException。这个 bug 花了我很长时间定位,因为复现条件非常苛刻。现在的规避措施是:保存快照前,确保应用处于"静止"状态——没有正在进行的网络请求、没有动画、没有定时任务 pending。通过 adb shell dumpsys activity processes 检查应用状态,确认主线程空闲。


第四个坑是快照与 Emulator 版本的不兼容。Android Emulator 31.3.x 到 32.1.x 之间,快照格式有过一次变更。我用 31.x 创建的快照,在 32.x 上恢复时直接崩溃,错误信息是 snapshot: invalid ram size in header。官方没有提供迁移工具,只能重新创建。这个经历让我养成了记录 Emulator 版本的习惯,快照命名中加入 emu_31.3 这样的标识。团队共享的快照必须明确标注创建时的 Emulator 版本,避免版本错配。


第五个坑是 ARM 翻译层的特殊行为。在 Apple Silicon Mac 上,Emulator 使用 Rosetta 或内置的 ARM 翻译来运行 x86 系统镜像。快照在这种环境下的兼容性更差,跨版本恢复失败率更高。我的建议是:Apple Silicon 开发机上尽量使用 arm64 系统镜像(arm64-v8a 标签的 AVD),虽然选择较少,但快照稳定性明显更好。Google 在 API 33 及以后提供了更多 arm64 镜像,这个限制正在缓解。


替代方案与互补工具


快照不是唯一的状态管理方案,理解它的边界才能正确使用。


Docker 化的 Android 环境(如 budtmo/docker-android)提供了另一种可复现性。它的优势在于完全声明式的配置,适合 CI 环境;劣势是图形性能差,无法运行需要 GPU 的测试场景,且启动时间并不比快照恢复更快。我目前的分工是:本地开发用 Emulator 快照,CI 用 Docker 做轻量级单元测试,Firebase Test Lab 做最终的设备兼容性验证。


Android Studio 的 "Device Mirroring" 功能(2022.1 版本引入)和快照是互补关系。Device Mirroring 让你用物理设备的实时画面做调试,但无法保存状态。对于需要反复验证的特定状态,物理设备上只能靠人工操作重现,效率远低于 Emulator 快照。


另一个相关工具是 android-test-plugin 中的 GrantPermissionRule,它能在测试运行时自动授予权限。这和快照的关系是:如果权限授予是测试前置条件的一部分,可以放在基线快照里,也可以每次测试用 Rule 授予。我的选择取决于权限授予的耗时:运行时授予很快(<100ms),用 Rule;需要跳转到系统设置的手动授予(如无障碍服务权限),放在快照里。


一个完整的配置示例


以我正在维护的支付模块测试环境为例,说明快照的完整配置流程。


AVD 配置:Pixel 7 外观,API 34,Google Play 镜像,4GB RAM,2GB 存储数据分区。创建后首次启动,完成系统初始化向导,登录测试 Google 账号(一个专门用于测试的 Gmail,开启了许可证测试)。


进入系统设置,关闭 Play 商店自动更新,关闭系统更新检查。安装应用测试版本,完成首次启动引导,进入主界面。此时保存第一个快照 baseline_fresh_install


然后进入应用内购买流程,完成测试信用卡绑定(使用 Google Play 的测试卡号 4111111111111111,这是官方文档公开的测试卡),在购买确认前的界面暂停。保存第二个快照 payment_ready_test_card


继续完成购买,确认交易成功,回到主界面。保存第三个快照 payment_completed


现在我有三个快照,覆盖了支付测试的关键节点。日常开发中,测试购买流程异常时从 payment_ready_test_card 启动;测试购买后的状态变化时从 payment_completed 启动;需要验证新用户引导时从 baseline_fresh_install 启动。


这个快照集合的总存储占用约 3.2GB,托管在团队的内部 MinIO 对象存储上。新成员加入时,下载 AVD 镜像和快照集合,配置好 Emulator 路径,即可在 10 分钟内拥有和资深成员一致的测试环境。这个"环境即代码"的近似实现,是我们能在远程协作中保持测试一致性的关键。


版本演进与未来观察


Android Emulator 的快照机制在近两年有一些值得关注的变更。


2023 年的 Emulator 33.x 系列引入了"增量快照"的实验性支持。传统快照每次保存都是完整内存镜像,增量快照只保存与基线的差异。这个功能在 config.ini 中通过 snapshot.incremental = true 开启,我测试过几个版本,稳定性还不够生产使用,但存储节省效果明显:一系列相关快照的总占用从 4GB 降到 1.2GB。官方 issue tracker 上,这个功能的标签是 emulator-snapshot-incremental,值得关注。


另一个方向是云端快照同步。Android Studio 的 "Cloud Emulators" 实验功能(需要登录 JetBrains Account 或 Google 账号)理论上支持快照的云端保存和跨设备恢复。实际测试中,上传一个 1GB 快照到云端需要 15 分钟以上(取决于上行带宽),恢复时的下载同样缓慢。在宽带基础设施改善之前,这个功能的实用价值有限。


Google 在 2024 年的 I/O 大会上提到了 "Emulator snapshots in version control" 的探索,但没有具体技术细节。我个人不太看好这个方向——快照的二进制大文件与 Git 的文本差异模型天然冲突,现有的 Git LFS 方案已经能很好解决。更需要的是快照的语义化管理工具,比如自动记录快照对应的代码版本、自动清理过期的缺陷快照。


写在最后


Android Emulator 的快照功能是一个被低估的生产力工具。它的技术基础成熟(QEMU 快照机制已有十余年历史),官方集成完善,但文档宣传和最佳实践传播不足。很多开发者停留在"知道能保存状态"的层面,没有把它系统性地纳入开发和测试工作流。


我的建议是:从为一个核心测试场景创建基线快照开始,体会跳过重复环境搭建的快感;然后逐步扩展到缺陷现场保存、长流程中间状态、版本兼容性矩阵等场景;最后与团队的 CI/CD 流程集成,实现环境的一致性和可复现性。


这个过程中你会遇到存储空间的压力、版本兼容的坑、时间敏感逻辑的陷阱。这些都是真实的工程权衡,没有完美的解决方案,只有适合当前团队状态的取舍。快照功能的价值不在于它本身有多强大,而在于它让你把时间和精力从"准备测试环境"转移到"实际测试和调试"上——而后者才是我们作为开发者的核心工作。


我现在创建新 AVD 后的第一件事,就是配置好基线环境并保存快照。这个习惯已经持续一年多,它节省的时间难以精确统计,但那个被反复打断的下午之后,我再也没有因为环境重置而浪费两个小时。

Compose 性能分析:Layout Inspector 之外还有什么 2026-06-29
LocalBroadcastManager 的废弃,应用内通信用什么替代 2026-06-29

评论区