APK 扩展文件

Google Play 要求用户下载的压缩 APK 大小不得超过 100MB。对于大多数应用而言,这足以容纳所有应用代码和资产。但是,有些应用需要更多空间来存储高保真图形、媒体文件或其他大型资产。以前,如果您的应用的压缩下载大小超过 100MB,您必须在用户打开应用时自行托管和下载额外资源。托管和提供额外文件可能成本高昂,而且用户体验通常不理想。为了让此过程对您而言更轻松,并让用户体验更愉快,Google Play 允许您附加两个大型扩展文件来补充您的 APK。

Google Play 免费为您托管应用的扩展文件,并将其提供给设备。扩展文件保存到设备的共享存储位置(SD 卡或 USB 可装载分区;也称为“外部”存储),您的应用可以在此处访问它们。在大多数设备上,Google Play 会在下载 APK 的同时下载扩展文件,因此您的应用在用户首次打开时便拥有所需的一切。但在某些情况下,您的应用必须在启动时从 Google Play 下载文件。

如果您想避免使用扩展文件,并且您的应用的压缩下载大小超过 100MB,则应改用 Android App Bundle 上传您的应用,它允许最大 200MB 的压缩下载大小。此外,由于使用应用 bundle 将 APK 生成和签名推迟到 Google Play,因此用户下载的是经过优化的 APK,其中只包含运行您的应用所需的代码和资源。您无需构建、签名和管理多个 APK 或扩展文件,用户也可以获得更小、更优化的下载。

概述

每次使用 Google Play 管理中心上传 APK 时,您都可以选择向 APK 添加一个或两个扩展文件。每个文件最大可达 2GB,可以是您选择的任何格式,但我们建议您使用压缩文件以在下载期间节省带宽。从概念上讲,每个扩展文件扮演不同的角色

  • 主要扩展文件是您的应用所需的额外资源的主要扩展文件。
  • 补丁扩展文件是可选的,用于对主要扩展文件进行少量更新。

虽然您可以随意使用这两个扩展文件,但我们建议主要扩展文件提供主要资产,并且几乎从不更新;补丁扩展文件应更小,并作为“补丁载体”,在每次主要发布时或必要时进行更新。

但是,即使您的应用更新只需要新的补丁扩展文件,您仍然必须上传一个包含清单中更新后的 versionCode 的新 APK。(Play 管理中心不允许您将扩展文件上传到现有 APK。)

注意:补丁扩展文件在语义上与主扩展文件相同——您可以随意使用每个文件。

文件名格式

您上传的每个扩展文件可以是您选择的任何格式(ZIP、PDF、MP4 等)。您还可以使用 JOBB 工具封装和加密一组资源文件以及该组的后续补丁。无论文件类型如何,Google Play 都将其视为不透明的二进制数据块,并使用以下方案重命名文件

[main|patch].<expansion-version>.<package-name>.obb

此方案有三个组成部分

mainpatch
指定文件是主要扩展文件还是补丁扩展文件。每个 APK 只能有一个主要文件和一个补丁文件。
<expansion-version>
这是一个整数,与扩展文件首次关联的 APK 的版本代码相匹配(它与应用的 android:versionCode 值相匹配)。

强调“首次”是因为尽管 Play 管理中心允许您将已上传的扩展文件与新 APK 重复使用,但扩展文件的名称不会改变——它保留了您首次上传文件时应用于它的版本。

<package-name>
您的应用的 Java 风格包名。

例如,假设您的 APK 版本为 314159,包名为 com.example.app。如果您上传主要扩展文件,该文件将被重命名为

main.314159.com.example.app.obb

存储位置

当 Google Play 将您的扩展文件下载到设备时,它会将它们保存到系统的共享存储位置。为确保正常行为,您不得删除、移动或重命名扩展文件。如果您的应用必须自行从 Google Play 执行下载,则必须将文件保存到完全相同的位置。

getObbDir() 方法以以下形式返回您的扩展文件的特定位置

<shared-storage>/Android/obb/<package-name>/

对于每个应用,此目录中永远不会超过两个扩展文件。一个是主要扩展文件,另一个是补丁扩展文件(如果需要)。当您使用新的扩展文件更新应用时,旧版本将被覆盖。自 Android 4.4(API 级别 19)以来,应用无需外部存储权限即可读取 OBB 扩展文件。但是,Android 6.0(API 级别 23)及更高版本的一些实现仍然需要权限,因此您需要在应用清单中声明 READ_EXTERNAL_STORAGE 权限,并按如下方式在运行时请求权限

<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />

对于 Android 6 及更高版本,需要运行时请求外部存储权限。但是,某些 Android 实现不需要权限即可读取 OBB 文件。以下代码片段显示了在请求外部存储权限之前如何检查读取访问权限

Kotlin

val obb = File(obb_filename)
var open_failed = false

try {
    BufferedReader(FileReader(obb)).also { br ->
        ReadObbFile(br)
    }
} catch (e: IOException) {
    open_failed = true
}

if (open_failed) {
    // request READ_EXTERNAL_STORAGE permission before reading OBB file
    ReadObbFileWithPermission()
}

Java

File obb = new File(obb_filename);
 boolean open_failed = false;

 try {
     BufferedReader br = new BufferedReader(new FileReader(obb));
     open_failed = false;
     ReadObbFile(br);
 } catch (IOException e) {
     open_failed = true;
 }

 if (open_failed) {
     // request READ_EXTERNAL_STORAGE permission before reading OBB file
     ReadObbFileWithPermission();
 }

如果您必须解压缩扩展文件的内容,请不要随后删除 OBB 扩展文件,也不要将解压缩的数据保存在同一目录中。您应该将解压缩的文件保存到 getExternalFilesDir() 指定的目录中。但是,如果可能,最好使用允许您直接从文件读取的扩展文件格式,而不是要求您解压缩数据。例如,我们提供了一个名为APK 扩展 Zip 库的库项目,它直接从 ZIP 文件中读取数据。

注意:与 APK 文件不同,共享存储上保存的任何文件都可以被用户和其他应用读取。

提示:如果您将媒体文件打包到 ZIP 中,您可以使用具有偏移和长度控制的媒体播放调用(例如 MediaPlayer.setDataSource()SoundPool.load())直接从文件读取,而无需解压缩 ZIP。为了使此功能正常工作,在创建 ZIP 包时,您不得对媒体文件进行额外压缩。例如,使用 zip 工具时,您应该使用 -n 选项指定不应压缩的文件后缀
zip -n .mp4;.ogg main_expansion media_files

下载过程

大多数情况下,Google Play 会在下载 APK 到设备的同时下载并保存您的扩展文件。但是,在某些情况下,Google Play 无法下载扩展文件,或者用户可能已删除以前下载的扩展文件。为了处理这些情况,您的应用必须能够在主活动启动时自行下载文件,使用 Google Play 提供的 URL。

高层次的下载过程如下

  1. 用户选择从 Google Play 安装您的应用。
  2. 如果 Google Play 能够下载扩展文件(大多数设备都是如此),它会连同 APK 一起下载它们。

    如果 Google Play 无法下载扩展文件,它将只下载 APK。

  3. 当用户启动您的应用时,您的应用必须检查扩展文件是否已保存在设备上。
    1. 如果是,您的应用已准备就绪。
    2. 如果否,您的应用必须通过 HTTP 从 Google Play 下载扩展文件。您的应用必须使用 Google Play 的应用许可服务向 Google Play 客户端发送请求,该服务会响应每个扩展文件的名称、文件大小和 URL。有了这些信息,您就可以下载文件并将其保存到正确的存储位置

注意:当您的应用启动时,如果文件尚未在设备上,您必须包含从 Google Play 下载扩展文件的必要代码。如以下关于下载扩展文件的部分所述,我们提供了一个库,可以极大地简化此过程,并以最少的代码从服务执行下载。

开发清单

以下是您应该为应用使用扩展文件而执行的任务摘要

  1. 首先确定您的应用的压缩下载大小是否需要超过 100MB。空间很宝贵,您应该尽可能减小总下载大小。如果您的应用使用超过 100MB 以提供适用于多种屏幕密度的多种图形资产版本,请考虑改为发布多个 APK,其中每个 APK 仅包含其目标屏幕所需的资产。为了在发布到 Google Play 时获得最佳结果,请上传 Android App Bundle,其中包含您的应用的所有已编译代码和资源,但将 APK 生成和签名推迟到 Google Play。
  2. 确定要从 APK 中分离并打包到文件中以用作主要扩展文件的应用资源。

    通常,您只应在更新主要扩展文件时使用第二个补丁扩展文件。但是,如果您的资源超过主要扩展文件的 2GB 限制,您可以将补丁文件用于其余资产。

  3. 开发您的应用,使其使用设备共享存储位置中的扩展文件资源。

    请记住,您不得删除、移动或重命名扩展文件。

    如果您的应用不要求特定格式,我们建议您为扩展文件创建 ZIP 文件,然后使用APK 扩展 Zip 库读取它们。

  4. 在您的应用主活动中添加逻辑,检查启动时扩展文件是否在设备上。如果文件不在设备上,请使用 Google Play 的应用许可服务请求扩展文件的 URL,然后下载并保存它们。

    为了大幅减少您必须编写的代码量并确保下载期间良好的用户体验,我们建议您使用下载器库来实现您的下载行为。

    如果您构建自己的下载服务而不是使用该库,请注意您不得更改扩展文件的名称,并且必须将它们保存到正确的存储位置

完成应用开发后,请按照测试您的扩展文件指南进行操作。

规则和限制

添加 APK 扩展文件是您使用 Play 管理中心上传应用时可用的功能。首次上传应用或更新使用扩展文件的应用时,您必须注意以下规则和限制

  1. 每个扩展文件不得超过 2GB。
  2. 为了从 Google Play 下载您的扩展文件,用户必须从 Google Play 获取您的应用。如果应用是通过其他方式安装的,Google Play 将不会提供您的扩展文件的 URL。
  3. 在您的应用内执行下载时,Google Play 为每个文件提供的 URL 对于每次下载都是唯一的,并且在提供给您的应用后不久就会过期。
  4. 如果您使用新的 APK 更新您的应用,或者为同一个应用上传多个 APK,您可以选择您为上一个 APK 上传的扩展文件。扩展文件的名称不会改变——它保留了文件最初关联的 APK 接收的版本。
  5. 如果您将扩展文件与多个 APK 结合使用,以便为不同的设备提供不同的扩展文件,您仍然必须为每台设备上传单独的 APK,以提供唯一的 versionCode 值并为每个 APK 声明不同的过滤器
  6. 您不能仅通过更改扩展文件来更新您的应用——您必须上传一个新的 APK 才能更新您的应用。如果您的更改只涉及扩展文件中的资产,您可以通过简单地更改清单中的 versionCode(可能还有 versionName)来更新您的 APK。
  7. 不要将其他数据保存到您的 obb/ 目录中。如果您必须解压某些数据,请将其保存到 getExternalFilesDir() 指定的位置。
  8. 不要删除或重命名 .obb 扩展文件(除非您正在执行更新)。这样做将导致 Google Play(或您的应用本身)反复下载扩展文件。
  9. 手动更新扩展文件时,必须删除之前的扩展文件。

下载扩展文件

在大多数情况下,Google Play 会在安装或更新 APK 的同时将扩展文件下载并保存到设备。这样,当您的应用首次启动时,扩展文件就可用了。但是,在某些情况下,您的应用必须自行下载扩展文件,方法是从 Google Play 的应用许可服务的响应中提供的 URL 请求它们。

您下载扩展文件所需的基本逻辑如下

  1. 当您的应用启动时,在共享存储位置(在 Android/obb/<package-name>/ 目录中)查找扩展文件。
    1. 如果扩展文件存在,则一切就绪,您的应用可以继续。
    2. 如果扩展文件存在
      1. 使用 Google Play 的应用许可执行请求,以获取您应用的扩展文件名、大小和 URL。
      2. 使用 Google Play 提供的 URL 下载扩展文件并保存它们。您必须将文件保存到共享存储位置Android/obb/<package-name>/),并使用 Google Play 响应中提供的确切文件名。

        注意:Google Play 为您的扩展文件提供的 URL 对于每次下载都是唯一的,并且在提供给您的应用后不久就会过期。

如果您的应用是免费的(不是付费应用),那么您可能没有使用应用许可服务。它主要旨在强制执行您的应用的许可政策,并确保用户有权使用您的应用(他们已在 Google Play 上合法付费购买)。为了方便扩展文件功能,许可服务已得到增强,可向您的应用提供一个响应,其中包含托管在 Google Play 上的应用扩展文件的 URL。因此,即使您的应用对用户免费,您也需要包含许可验证库 (LVL) 才能使用 APK 扩展文件。当然,如果您的应用是免费的,您不需要强制执行许可验证——您只需要该库来执行返回扩展文件 URL 的请求。

注意:无论您的应用是否免费,Google Play 仅在用户从 Google Play 获取您的应用时才返回扩展文件 URL。

除了 LVL 之外,您还需要一套代码,通过 HTTP 连接下载扩展文件并将其保存到设备共享存储上的正确位置。在您的应用中构建此过程时,应考虑以下几个问题

  • 设备可能没有足够的空间用于扩展文件,因此您应该在开始下载之前检查并警告用户空间不足。
  • 文件下载应该在后台服务中进行,以避免阻塞用户交互,并允许用户在下载完成时离开您的应用。
  • 在请求和下载过程中可能会发生各种错误,您必须优雅地处理它们。
  • 网络连接在下载过程中可能会发生变化,因此您应该处理这些变化,如果中断,应在可能的情况下恢复下载。
  • 当下载在后台进行时,您应该提供一个通知,指示下载进度,在完成后通知用户,并在选中时将用户带回您的应用。

为了简化您的工作,我们构建了下载器库,它通过许可服务请求扩展文件 URL,下载扩展文件,执行上述所有任务,甚至允许您的活动暂停和恢复下载。通过将下载器库和一些代码挂钩添加到您的应用中,几乎所有下载扩展文件的工作都已为您编写好。因此,为了以最小的努力提供最佳用户体验,我们建议您使用下载器库下载扩展文件。以下部分的信息解释了如何将该库集成到您的应用中。

如果您更愿意开发自己的解决方案来使用 Google Play URL 下载扩展文件,则必须遵循应用许可文档来执行许可请求,然后从响应附加数据中检索扩展文件名、大小和 URL。您应该使用 APKExpansionPolicy 类(包含在许可验证库中)作为您的许可政策,它从许可服务捕获扩展文件名、大小和 URL。

关于下载器库

为了在您的应用中使用 APK 扩展文件并以最小的努力提供最佳用户体验,我们建议您使用 Google Play APK 扩展库包中包含的下载器库。该库在后台服务中下载您的扩展文件,通过用户通知显示下载状态,处理网络连接丢失,在可能时恢复下载等等。

要使用下载器库实现扩展文件下载,您只需

  • 扩展一个特殊的 Service 子类和 BroadcastReceiver 子类,每个都只需要您编写几行代码。
  • 在您的主活动中添加一些逻辑,检查扩展文件是否已下载,如果未下载,则调用下载过程并显示进度 UI。
  • 在您的主活动中实现一个回调接口,其中包含一些方法,用于接收下载进度的更新。

以下部分解释了如何使用下载器库设置您的应用。

准备使用下载器库

要使用下载器库,您需要从 SDK 管理器下载两个软件包,并将相应的库添加到您的应用中。

首先,打开 Android SDK 管理器工具 > SDK 管理器),然后在外观与行为 > 系统设置 > Android SDK 下,选择SDK 工具选项卡以选择并下载

  • Google Play 许可库软件包
  • Google Play APK 扩展库软件包

为许可验证库和下载器库创建一个新的库模块。对于每个库

  1. 选择文件 > 新建 > 新建模块
  2. 创建新模块窗口中,选择Android 库,然后选择下一步
  3. 指定一个应用/库名称,例如“Google Play License Library”和“Google Play Downloader Library”,选择最低 SDK 级别,然后选择完成
  4. 选择文件 > 项目结构
  5. 选择属性选项卡,并在库存储库中,输入来自 <sdk>/extras/google/ 目录的库(许可验证库为 play_licensing/,下载器库为 play_apk_expansion/downloader_library/)。
  6. 选择确定以创建新模块。

注意:下载器库依赖于许可验证库。请务必将许可验证库添加到下载器库的项目属性中。

或者,从命令行更新您的项目以包含这些库

  1. 将目录更改为 <sdk>/tools/ 目录。
  2. 执行 android update project 并带上 --library 选项,以将 LVL 和下载器库添加到您的项目。例如
    android update project --path ~/Android/MyApp \
    --library ~/android_sdk/extras/google/market_licensing \
    --library ~/android_sdk/extras/google/market_apk_expansion/downloader_library
    

将许可验证库和下载器库都添加到您的应用后,您将能够快速集成从 Google Play 下载扩展文件的功能。您为扩展文件选择的格式以及如何从共享存储中读取它们是另一个单独的实现,您应根据您的应用需求进行考虑。

提示:APK 扩展包中包含一个示例应用,展示了如何在应用中使用下载器库。该示例使用了 APK 扩展包中提供的另一个库,名为 APK 扩展 Zip 库。如果您计划将 ZIP 文件用于扩展文件,我们建议您也将 APK 扩展 Zip 库添加到您的应用中。更多信息请参见下方关于使用 APK 扩展 Zip 库的部分。

声明用户权限

为了下载扩展文件,下载器库需要您在应用的清单文件中声明的几个权限。它们是

<manifest ...>
    <!-- Required to access Google Play Licensing -->
    <uses-permission android:name="com.android.vending.CHECK_LICENSE" />

    <!-- Required to download files from Google Play -->
    <uses-permission android:name="android.permission.INTERNET" />

    <!-- Required to keep CPU alive while downloading files
        (NOT to keep screen awake) -->
    <uses-permission android:name="android.permission.WAKE_LOCK" />

    <!-- Required to poll the state of the network connection
        and respond to changes -->
    <uses-permission
        android:name="android.permission.ACCESS_NETWORK_STATE" />

    <!-- Required to check whether Wi-Fi is enabled -->
    <uses-permission android:name="android.permission.ACCESS_WIFI_STATE"/>

    <!-- Required to read and write the expansion files on shared storage -->
    <uses-permission
        android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
    ...
</manifest>

注意:默认情况下,下载器库需要 API 级别 4,但 APK 扩展 Zip 库需要 API 级别 5。

实现下载器服务

为了在后台执行下载,下载器库提供了它自己的 Service 子类,名为 DownloaderService,您应该对其进行扩展。除了为您下载扩展文件外,DownloaderService

  • 注册一个 BroadcastReceiver,它监听设备网络连接的变化(CONNECTIVITY_ACTION 广播),以便在必要时(例如由于连接丢失)暂停下载,并在可能时(连接恢复)恢复下载。
  • 调度一个 RTC_WAKEUP 闹钟,以在服务被杀死的情况下重试下载。
  • 构建一个自定义 Notification,显示下载进度以及任何错误或状态变化。
  • 允许您的应用手动暂停和恢复下载。
  • 在下载扩展文件之前,验证共享存储是否已装载并可用,文件是否已存在,以及是否有足够的空间。如果这些条件中任何一个不满足,则通知用户。

您只需在应用中创建一个扩展 DownloaderService 类并覆盖三个方法以提供特定的应用详细信息

getPublicKey()
这必须返回一个字符串,即您的发布者账户的 Base64 编码 RSA 公钥,可从 Play 管理中心的个人资料页面获取(参见设置许可)。
getSALT()
这必须返回一个随机字节数组,许可 Policy 使用它来创建 Obfuscator。盐值确保您的混淆 SharedPreferences 文件(其中保存了您的许可数据)是唯一的且不可发现的。
getAlarmReceiverClassName()
这必须返回您的应用中 BroadcastReceiver 的类名,该接收器应接收指示应重新启动下载的闹钟(如果下载器服务意外停止,可能会发生这种情况)。

例如,以下是 DownloaderService 的完整实现

Kotlin

// You must use the public key belonging to your publisher account
const val BASE64_PUBLIC_KEY = "YourLVLKey"
// You should also modify this salt
val SALT = byteArrayOf(
        1, 42, -12, -1, 54, 98, -100, -12, 43, 2,
        -8, -4, 9, 5, -106, -107, -33, 45, -1, 84
)

class SampleDownloaderService : DownloaderService() {

    override fun getPublicKey(): String = BASE64_PUBLIC_KEY

    override fun getSALT(): ByteArray = SALT

    override fun getAlarmReceiverClassName(): String = SampleAlarmReceiver::class.java.name
}

Java

public class SampleDownloaderService extends DownloaderService {
    // You must use the public key belonging to your publisher account
    public static final String BASE64_PUBLIC_KEY = "YourLVLKey";
    // You should also modify this salt
    public static final byte[] SALT = new byte[] { 1, 42, -12, -1, 54, 98,
            -100, -12, 43, 2, -8, -4, 9, 5, -106, -107, -33, 45, -1, 84
    };

    @Override
    public String getPublicKey() {
        return BASE64_PUBLIC_KEY;
    }

    @Override
    public byte[] getSALT() {
        return SALT;
    }

    @Override
    public String getAlarmReceiverClassName() {
        return SampleAlarmReceiver.class.getName();
    }
}

注意:您必须将 BASE64_PUBLIC_KEY 值更新为您发布者账户的公钥。您可以在开发者控制台的个人资料信息中找到该密钥。即使在测试下载时,这也是必要的。

请记住在您的清单文件中声明服务

<app ...>
    <service android:name=".SampleDownloaderService" />
    ...
</app>

实现闹钟接收器

为了监控文件下载进度并在必要时重新启动下载,DownloaderService 安排了一个 RTC_WAKEUP 闹钟,该闹钟将一个 Intent 传递到您应用中的 BroadcastReceiver。您必须定义 BroadcastReceiver 以调用下载器库中的 API,该 API 检查下载状态并在必要时重新启动下载。

您只需覆盖 onReceive() 方法以调用 DownloaderClientMarshaller.startDownloadServiceIfRequired()

例如

Kotlin

class SampleAlarmReceiver : BroadcastReceiver() {

    override fun onReceive(context: Context, intent: Intent) {
        try {
            DownloaderClientMarshaller.startDownloadServiceIfRequired(
                    context,
                    intent,
                    SampleDownloaderService::class.java
            )
        } catch (e: PackageManager.NameNotFoundException) {
            e.printStackTrace()
        }
    }
}

Java

public class SampleAlarmReceiver extends BroadcastReceiver {
    @Override
    public void onReceive(Context context, Intent intent) {
        try {
            DownloaderClientMarshaller.startDownloadServiceIfRequired(context,
                intent, SampleDownloaderService.class);
        } catch (NameNotFoundException e) {
            e.printStackTrace();
        }
    }
}

请注意,这是您必须在服务的 getAlarmReceiverClassName() 方法中返回名称的类(参见上一节)。

请记住在您的清单文件中声明接收器

<app ...>
    <receiver android:name=".SampleAlarmReceiver" />
    ...
</app>

开始下载

您应用中的主要活动(由您的启动器图标启动的活动)负责验证扩展文件是否已在设备上,如果不在,则启动下载。

使用下载器库启动下载需要以下步骤

  1. 检查文件是否已下载。

    下载器库在 Helper 类中包含一些 API,以帮助完成此过程

    • getExpansionAPKFileName(Context, c, boolean mainFile, int versionCode)
    • doesFileExist(Context c, String fileName, long fileSize)

    例如,APK 扩展包中提供的示例应用在活动的 onCreate() 方法中调用以下方法,以检查扩展文件是否已存在于设备上

    Kotlin

    fun expansionFilesDelivered(): Boolean {
        xAPKS.forEach { xf ->
            Helpers.getExpansionAPKFileName(this, xf.isBase, xf.fileVersion).also { fileName ->
                if (!Helpers.doesFileExist(this, fileName, xf.fileSize, false))
                    return false
            }
        }
        return true
    }

    Java

    boolean expansionFilesDelivered() {
        for (XAPKFile xf : xAPKS) {
            String fileName = Helpers.getExpansionAPKFileName(this, xf.isBase,
                xf.fileVersion);
            if (!Helpers.doesFileExist(this, fileName, xf.fileSize, false))
                return false;
        }
        return true;
    }

    在这种情况下,每个 XAPKFile 对象都包含已知扩展文件的版本号和文件大小,以及它是否是主要扩展文件的布尔值。(有关详细信息,请参阅示例应用的 SampleDownloaderActivity 类。)

    如果此方法返回 false,则应用必须开始下载。

  2. 通过调用静态方法 DownloaderClientMarshaller.startDownloadServiceIfRequired(Context c, PendingIntent notificationClient, Class<?> serviceClass) 来开始下载。

    该方法接受以下参数

    • context:您的应用的 Context
    • notificationClient:一个 PendingIntent,用于启动您的主活动。这用于 DownloaderService 创建的 Notification,以显示下载进度。当用户选择通知时,系统会调用您在此处提供的 PendingIntent,并应打开显示下载进度的活动(通常是启动下载的同一活动)。
    • serviceClass:您的 DownloaderService 实现的 Class 对象,如果需要,用于启动服务并开始下载。

    该方法返回一个整数,指示是否需要下载。可能的值有

    • NO_DOWNLOAD_REQUIRED:如果文件已存在或下载正在进行中,则返回。
    • LVL_CHECK_REQUIRED:如果需要许可验证才能获取扩展文件 URL,则返回。
    • DOWNLOAD_REQUIRED:如果扩展文件 URL 已知但尚未下载,则返回。

    LVL_CHECK_REQUIREDDOWNLOAD_REQUIRED 的行为基本相同,您通常无需担心它们。在调用 startDownloadServiceIfRequired() 的主活动中,您只需检查响应是否为 NO_DOWNLOAD_REQUIRED。如果响应不是 NO_DOWNLOAD_REQUIRED,下载器库将开始下载,您应该更新活动 UI 以显示下载进度(参见下一步)。如果响应 NO_DOWNLOAD_REQUIRED,则文件可用,您的应用可以启动。

    例如

    Kotlin

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
    
        // Check if expansion files are available before going any further
        if (!expansionFilesDelivered()) {
            val pendingIntent =
                    // Build an Intent to start this activity from the Notification
                    Intent(this, MainActivity::class.java).apply {
                        flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
                    }.let { notifierIntent ->
                        PendingIntent.getActivity(
                                this,
                                0,
                                notifierIntent,
                                PendingIntent.FLAG_UPDATE_CURRENT
                        )
                    }
    
    
            // Start the download service (if required)
            val startResult: Int = DownloaderClientMarshaller.startDownloadServiceIfRequired(
                    this,
                    pendingIntent,
                    SampleDownloaderService::class.java
            )
            // If download has started, initialize this activity to show
            // download progress
            if (startResult != DownloaderClientMarshaller.NO_DOWNLOAD_REQUIRED) {
                // This is where you do set up to display the download
                // progress (next step)
                ...
                return
            } // If the download wasn't necessary, fall through to start the app
        }
        startApp() // Expansion files are available, start the app
    }

    Java

    @Override
    public void onCreate(Bundle savedInstanceState) {
        // Check if expansion files are available before going any further
        if (!expansionFilesDelivered()) {
            // Build an Intent to start this activity from the Notification
            Intent notifierIntent = new Intent(this, MainActivity.getClass());
            notifierIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK |
                                    Intent.FLAG_ACTIVITY_CLEAR_TOP);
            ...
            PendingIntent pendingIntent = PendingIntent.getActivity(this, 0,
                    notifierIntent, PendingIntent.FLAG_UPDATE_CURRENT);
    
            // Start the download service (if required)
            int startResult =
                DownloaderClientMarshaller.startDownloadServiceIfRequired(this,
                            pendingIntent, SampleDownloaderService.class);
            // If download has started, initialize this activity to show
            // download progress
            if (startResult != DownloaderClientMarshaller.NO_DOWNLOAD_REQUIRED) {
                // This is where you do set up to display the download
                // progress (next step)
                ...
                return;
            } // If the download wasn't necessary, fall through to start the app
        }
        startApp(); // Expansion files are available, start the app
    }
  3. startDownloadServiceIfRequired() 方法返回 NO_DOWNLOAD_REQUIRED 以外的任何值时,通过调用 DownloaderClientMarshaller.CreateStub(IDownloaderClient client, Class<?> downloaderService) 创建一个 IStub 实例。IStub 提供您的活动与下载器服务之间的绑定,以便您的活动接收有关下载进度的回调。

    为了通过调用 CreateStub() 实例化您的 IStub,您必须向其传递 IDownloaderClient 接口的实现和您的 DownloaderService 实现。下一节关于接收下载进度讨论了 IDownloaderClient 接口,您通常应该在您的 Activity 类中实现它,以便在下载状态更改时更新活动 UI。

    我们建议您在活动的 onCreate() 方法中,在 startDownloadServiceIfRequired() 开始下载后,调用 CreateStub() 来实例化您的 IStub

    例如,在 onCreate() 的上一个代码示例中,您可以像这样响应 startDownloadServiceIfRequired() 的结果

    Kotlin

            // Start the download service (if required)
            val startResult = DownloaderClientMarshaller.startDownloadServiceIfRequired(
                    this@MainActivity,
                    pendingIntent,
                    SampleDownloaderService::class.java
            )
            // If download has started, initialize activity to show progress
            if (startResult != DownloaderClientMarshaller.NO_DOWNLOAD_REQUIRED) {
                // Instantiate a member instance of IStub
                downloaderClientStub =
                        DownloaderClientMarshaller.CreateStub(this, SampleDownloaderService::class.java)
                // Inflate layout that shows download progress
                setContentView(R.layout.downloader_ui)
                return
            }

    Java

            // Start the download service (if required)
            int startResult =
                DownloaderClientMarshaller.startDownloadServiceIfRequired(this,
                            pendingIntent, SampleDownloaderService.class);
            // If download has started, initialize activity to show progress
            if (startResult != DownloaderClientMarshaller.NO_DOWNLOAD_REQUIRED) {
                // Instantiate a member instance of IStub
                downloaderClientStub = DownloaderClientMarshaller.CreateStub(this,
                        SampleDownloaderService.class);
                // Inflate layout that shows download progress
                setContentView(R.layout.downloader_ui);
                return;
            }

    onCreate() 方法返回后,您的活动将收到对 onResume() 的调用,您应该在此处对 IStub 调用 connect(),并将应用的 Context 传递给它。相反,您应该在活动的 onStop() 回调中调用 disconnect()

    Kotlin

    override fun onResume() {
        downloaderClientStub?.connect(this)
        super.onResume()
    }
    
    override fun onStop() {
        downloaderClientStub?.disconnect(this)
        super.onStop()
    }

    Java

    @Override
    protected void onResume() {
        if (null != downloaderClientStub) {
            downloaderClientStub.connect(this);
        }
        super.onResume();
    }
    
    @Override
    protected void onStop() {
        if (null != downloaderClientStub) {
            downloaderClientStub.disconnect(this);
        }
        super.onStop();
    }

    IStub 调用 connect() 会将您的活动绑定到 DownloaderService,以便您的活动通过 IDownloaderClient 接口接收有关下载状态变化的回调。

接收下载进度

要接收有关下载进度的更新并与 DownloaderService 交互,您必须实现下载器库的 IDownloaderClient 接口。通常,您用于启动下载的活动应该实现此接口,以便显示下载进度并向服务发送请求。

IDownloaderClient 所需的接口方法是

onServiceConnected(Messenger m)
在您的活动中实例化 IStub 后,您将收到对此方法的调用,该方法会传递一个与您的 DownloaderService 实例连接的 Messenger 对象。要向服务发送请求(例如暂停和恢复下载),您必须调用 DownloaderServiceMarshaller.CreateProxy() 以接收连接到服务的 IDownloaderService 接口。

推荐的实现如下所示

Kotlin

private var remoteService: IDownloaderService? = null
...

override fun onServiceConnected(m: Messenger) {
    remoteService = DownloaderServiceMarshaller.CreateProxy(m).apply {
        downloaderClientStub?.messenger?.also { messenger ->
            onClientUpdated(messenger)
        }
    }
}

Java

private IDownloaderService remoteService;
...

@Override
public void onServiceConnected(Messenger m) {
    remoteService = DownloaderServiceMarshaller.CreateProxy(m);
    remoteService.onClientUpdated(downloaderClientStub.getMessenger());
}

初始化 IDownloaderService 对象后,您可以向下载器服务发送命令,例如暂停和恢复下载(requestPauseDownload()requestContinueDownload())。

onDownloadStateChanged(int newState)
当下载状态发生变化时(例如下载开始或完成),下载服务会调用此方法。

newState 值将是 IDownloaderClient 类的 STATE_* 常量之一指定的几个可能值之一。

为了向用户提供有用的消息,您可以调用 Helpers.getDownloaderStringResourceIDFromState() 来请求每个状态的相应字符串。这会返回下载器库中捆绑的某个字符串的资源 ID。例如,字符串“Download paused because you are roaming”对应于 STATE_PAUSED_ROAMING

onDownloadProgress(DownloadProgressInfo progress)
下载服务会调用此方法以传递一个 DownloadProgressInfo 对象,该对象描述了下载进度的各种信息,包括估计剩余时间、当前速度、总进度和总大小,以便您可以更新下载进度 UI。

提示:有关更新下载进度 UI 的这些回调示例,请参见 APK 扩展包随附的示例应用中的 SampleDownloaderActivity

您可能会发现有用的一些 IDownloaderService 接口的公共方法是

requestPauseDownload()
暂停下载。
requestContinueDownload()
恢复暂停的下载。
setDownloadFlags(int flags)
设置用户对可以下载文件的网络类型的偏好设置。当前实现支持一个标志 FLAGS_DOWNLOAD_OVER_CELLULAR,但您可以添加其他标志。默认情况下,此标志启用,因此用户必须连接到 Wi-Fi 才能下载扩展文件。您可能希望提供一个用户偏好设置,以启用通过蜂窝网络下载。在这种情况下,您可以调用

Kotlin

remoteService = DownloaderServiceMarshaller.CreateProxy(m).apply {
    ...
    setDownloadFlags(IDownloaderService.FLAGS_DOWNLOAD_OVER_CELLULAR)
}

Java

remoteService
    .setDownloadFlags(IDownloaderService.FLAGS_DOWNLOAD_OVER_CELLULAR);

使用 APKExpansionPolicy

如果您决定构建自己的下载器服务而不是使用 Google Play 下载器库,您仍应使用许可验证库中提供的 APKExpansionPolicyAPKExpansionPolicy 类与 ServerManagedPolicy(可在 Google Play 许可验证库中获得)几乎相同,但包含对 APK 扩展文件响应附加数据的额外处理。

注意:如果您确实使用下载器库(如上一节所述),该库会执行与 APKExpansionPolicy 的所有交互,因此您无需直接使用此类别。

该类包含帮助您获取有关可用扩展文件所需信息的方法

  • getExpansionURLCount()
  • getExpansionURL(int index)
  • getExpansionFileName(int index)
  • getExpansionFileSize(int index)

有关在使用下载器库时如何使用 APKExpansionPolicy 的更多信息,请参见将许可添加到您的应用的文档,其中解释了如何实现此类许可策略。

读取扩展文件

一旦您的 APK 扩展文件保存到设备上,您如何读取您的文件取决于您使用的文件类型。如概述中所述,您的扩展文件可以是您想要的任何类型的文件,但会使用特定的文件名格式重命名,并保存到 <shared-storage>/Android/obb/<package-name>/

无论您如何读取文件,您都应该始终首先检查外部存储是否可供读取。用户可能已将存储通过 USB 连接到计算机,或者实际已移除 SD 卡。

注意:当您的应用启动时,您应该始终通过调用 getExternalStorageState() 检查外部存储空间是否可用且可读。这会返回表示外部存储状态的几个可能字符串之一。为了使您的应用可读,返回值必须是 MEDIA_MOUNTED

获取文件名

概述中所述,您的 APK 扩展文件使用特定的文件命名格式保存

[main|patch].<expansion-version>.<package-name>.obb

要获取扩展文件的位置和名称,您应该使用 getExternalStorageDirectory()getPackageName() 方法来构建您的文件路径。

以下是您在应用中可以使用的方法,用于获取包含两个扩展文件的完整路径的数组

Kotlin

fun getAPKExpansionFiles(ctx: Context, mainVersion: Int, patchVersion: Int): Array<String> {
    val packageName = ctx.packageName
    val ret = mutableListOf<String>()
    if (Environment.getExternalStorageState() == Environment.MEDIA_MOUNTED) {
        // Build the full path to the app's expansion files
        val root = Environment.getExternalStorageDirectory()
        val expPath = File(root.toString() + EXP_PATH + packageName)

        // Check that expansion file path exists
        if (expPath.exists()) {
            if (mainVersion > 0) {
                val strMainPath = "$expPath${File.separator}main.$mainVersion.$packageName.obb"
                val main = File(strMainPath)
                if (main.isFile) {
                    ret += strMainPath
                }
            }
            if (patchVersion > 0) {
                val strPatchPath = "$expPath${File.separator}patch.$mainVersion.$packageName.obb"
                val main = File(strPatchPath)
                if (main.isFile) {
                    ret += strPatchPath
                }
            }
        }
    }
    return ret.toTypedArray()
}

Java

// The shared path to all app expansion files
private final static String EXP_PATH = "/Android/obb/";

static String[] getAPKExpansionFiles(Context ctx, int mainVersion,
      int patchVersion) {
    String packageName = ctx.getPackageName();
    Vector<String> ret = new Vector<String>();
    if (Environment.getExternalStorageState()
          .equals(Environment.MEDIA_MOUNTED)) {
        // Build the full path to the app's expansion files
        File root = Environment.getExternalStorageDirectory();
        File expPath = new File(root.toString() + EXP_PATH + packageName);

        // Check that expansion file path exists
        if (expPath.exists()) {
            if ( mainVersion > 0 ) {
                String strMainPath = expPath + File.separator + "main." +
                        mainVersion + "." + packageName + ".obb";
                File main = new File(strMainPath);
                if ( main.isFile() ) {
                        ret.add(strMainPath);
                }
            }
            if ( patchVersion > 0 ) {
                String strPatchPath = expPath + File.separator + "patch." +
                        mainVersion + "." + packageName + ".obb";
                File main = new File(strPatchPath);
                if ( main.isFile() ) {
                        ret.add(strPatchPath);
                }
            }
        }
    }
    String[] retArray = new String[ret.size()];
    ret.toArray(retArray);
    return retArray;
}

您可以通过向其传递您的应用 Context 和所需的扩展文件版本来调用此方法。

确定扩展文件版本号的方法有很多。一种简单的方法是在下载开始时,通过使用 APKExpansionPolicy 类的 getExpansionFileName(int index) 方法查询扩展文件名,将版本保存在 SharedPreferences 文件中。然后,您可以在需要访问扩展文件时通过读取 SharedPreferences 文件来获取版本代码。

有关从共享存储读取的更多信息,请参阅数据存储文档。

使用 APK 扩展 Zip 库

Google Market Apk 扩展包包含一个名为 APK 扩展 Zip 库的库(位于 <sdk>/extras/google/google_market_apk_expansion/zip_file/)。这是一个可选库,可帮助您在扩展文件保存为 ZIP 文件时读取它们。使用此库可以轻松地将资源从 ZIP 扩展文件读取为虚拟文件系统。

APK 扩展 Zip 库包含以下类和 API

APKExpansionSupport
提供一些方法来访问扩展文件名和 ZIP 文件
getAPKExpansionFiles()
上面显示的方法相同,返回两个扩展文件的完整文件路径。
getAPKExpansionZipFile(Context ctx, int mainVersion, int patchVersion)
返回一个表示主文件和补丁文件总和的 ZipResourceFile。也就是说,如果您同时指定 mainVersionpatchVersion,则此方法返回一个 ZipResourceFile,它提供对所有数据的读取访问权限,其中补丁文件的数据合并到主文件之上。
ZipResourceFile
表示共享存储上的 ZIP 文件,并执行所有工作以根据您的 ZIP 文件提供虚拟文件系统。您可以使用 APKExpansionSupport.getAPKExpansionZipFile() 或通过 ZipResourceFile 实例(通过将扩展文件的路径传递给它)获取一个实例。此类包含各种有用的方法,但您通常不需要访问其中大部分方法。一些重要的方法是
getInputStream(String assetPath)
提供一个 InputStream 来读取 ZIP 文件中的文件。assetPath 必须是所需文件的路径,相对于 ZIP 文件内容的根目录。
getAssetFileDescriptor(String assetPath)
为 ZIP 文件中的文件提供一个 AssetFileDescriptorassetPath 必须是所需文件的路径,相对于 ZIP 文件内容的根目录。这对于某些需要 AssetFileDescriptor 的 Android API 非常有用,例如某些 MediaPlayer API。
APEZProvider
大多数应用不需要使用此类。此类定义了一个 ContentProvider,它通过内容提供程序 Uri 编组 ZIP 文件中的数据,以便为某些期望通过 Uri 访问媒体文件的 Android API 提供文件访问权限。例如,如果您想使用 VideoView.setVideoURI() 播放视频,这非常有用。

跳过媒体文件的 ZIP 压缩

如果您使用扩展文件存储媒体文件,ZIP 文件仍然允许您使用提供偏移和长度控制的 Android 媒体播放调用(例如 MediaPlayer.setDataSource()SoundPool.load())。为了使此功能正常工作,在创建 ZIP 包时,您不得对媒体文件执行额外压缩。例如,使用 zip 工具时,您应该使用 -n 选项指定不应压缩的文件后缀

zip -n .mp4;.ogg main_expansion media_files

从 ZIP 文件读取

使用 APK 扩展 Zip 库时,从 ZIP 文件读取文件通常需要以下步骤

Kotlin

// Get a ZipResourceFile representing a merger of both the main and patch files
val expansionFile =
        APKExpansionSupport.getAPKExpansionZipFile(appContext, mainVersion, patchVersion)

// Get an input stream for a known file inside the expansion file ZIPs
expansionFile.getInputStream(pathToFileInsideZip).use {
    ...
}

Java

// Get a ZipResourceFile representing a merger of both the main and patch files
ZipResourceFile expansionFile =
    APKExpansionSupport.getAPKExpansionZipFile(appContext,
        mainVersion, patchVersion);

// Get an input stream for a known file inside the expansion file ZIPs
InputStream fileStream = expansionFile.getInputStream(pathToFileInsideZip);

上述代码通过读取主扩展文件和补丁文件中所有文件的合并映射,提供了对存在于任一扩展文件中的任何文件的访问。您只需向 getAPKExpansionFile() 方法提供您的应用 android.content.Context 以及主扩展文件和补丁文件的版本号。

如果您更喜欢从特定的扩展文件读取,您可以使用 ZipResourceFile 构造函数并提供所需扩展文件的路径

Kotlin

// Get a ZipResourceFile representing a specific expansion file
val expansionFile = ZipResourceFile(filePathToMyZip)

// Get an input stream for a known file inside the expansion file ZIPs
expansionFile.getInputStream(pathToFileInsideZip).use {
    ...
}

Java

// Get a ZipResourceFile representing a specific expansion file
ZipResourceFile expansionFile = new ZipResourceFile(filePathToMyZip);

// Get an input stream for a known file inside the expansion file ZIPs
InputStream fileStream = expansionFile.getInputStream(pathToFileInsideZip);

有关将此库用于扩展文件的更多信息,请查看示例应用的 SampleDownloaderActivity 类,其中包含用于使用 CRC 验证下载文件的额外代码。请注意,如果您将此示例作为自己实现的基础,它要求您在 xAPKS 数组中声明扩展文件的字节大小

测试您的扩展文件

在发布您的应用之前,您应该测试两件事:读取扩展文件和下载文件。

测试文件读取

在您将应用上传到 Google Play 之前,您应该测试您的应用从共享存储读取文件的能力。您只需将文件添加到设备共享存储上的适当位置并启动您的应用

  1. 在您的设备上,在 Google Play 将保存您的文件的共享存储上创建相应的目录。

    例如,如果您的包名为 com.example.android,您需要在共享存储空间上创建目录 Android/obb/com.example.android/。(将您的测试设备连接到计算机以装载共享存储并手动创建此目录。)

  2. 手动将扩展文件添加到该目录。确保您重命名文件以匹配 Google Play 将使用的文件名格式

    例如,无论文件类型如何,com.example.android 应用的主要扩展文件应为 main.0300110.com.example.android.obb。版本代码可以是您想要的任何值。请记住

    • 主要扩展文件始终以 main 开头,补丁文件以 patch 开头。
    • 包名始终与附加到 Google Play 上的 APK 的包名匹配。
  3. 现在扩展文件已在设备上,您可以安装并运行您的应用以测试您的扩展文件。

以下是有关处理扩展文件的一些提醒

  • 不要删除或重命名 .obb 扩展文件(即使您将数据解压到不同的位置)。这样做将导致 Google Play(或您的应用本身)反复下载扩展文件。
  • 不要将其他数据保存到您的 obb/ 目录中。如果您必须解压某些数据,请将其保存到 getExternalFilesDir() 指定的位置。

测试文件下载

由于您的应用有时必须在首次打开时手动下载扩展文件,因此测试此过程以确保您的应用可以成功查询 URL、下载文件并将其保存到设备非常重要。

要测试您的应用对手动下载过程的实现,您可以将其发布到内部测试轨道,以便仅授权测试人员可用。如果一切按预期工作,您的应用应该在主活动启动后立即开始下载扩展文件。

注意:以前您可以通过上传未发布的“草稿”版本来测试应用。此功能不再受支持。相反,您必须将其发布到内部、封闭或开放测试轨道。有关更多信息,请参见草稿应用不再受支持

更新您的应用

使用 Google Play 上的扩展文件的一大好处是能够在不重新下载所有原始资产的情况下更新您的应用。由于 Google Play 允许您为每个 APK 提供两个扩展文件,因此您可以使用第二个文件作为提供更新和新资产的“补丁”。这样做可以避免重新下载主扩展文件,这可能对用户来说很大且成本高昂。

补丁扩展文件在技术上与主扩展文件相同,Android 系统和 Google Play 都不会在您的主扩展文件和补丁扩展文件之间执行实际的修补。您的应用代码必须自行执行任何必要的修补。

如果您将 ZIP 文件用作扩展文件,APK 扩展包中包含的APK 扩展 Zip 库包含将补丁文件与主扩展文件合并的功能。

注意:即使您只需要更改补丁扩展文件,您仍然必须更新 APK 才能让 Google Play 执行更新。如果您不需要更改应用代码,您应该只需更新清单中的 versionCode

只要您不更改 Play 管理中心内与 APK 关联的主扩展文件,以前安装过您应用的用户就不会下载主扩展文件。现有用户只会收到更新后的 APK 和新的补丁扩展文件(保留之前的主扩展文件)。

以下是有关扩展文件更新的几个注意事项:

  • 您的应用一次只能有两个扩展文件:一个主扩展文件和一个补丁扩展文件。在文件更新期间,Google Play 会删除旧版本(因此您的应用在执行手动更新时也必须删除旧版本)。
  • 添加补丁扩展文件时,Android 系统并不会实际修补您的应用或主扩展文件。您必须设计您的应用以支持补丁数据。然而,Apk Expansion 软件包包含一个用于将 ZIP 文件用作扩展文件的库,该库会将补丁文件中的数据合并到主扩展文件中,以便您可以轻松读取所有扩展文件数据。