APK 扩展文件

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

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

如果您想避免使用扩展文件,并且您的应用的压缩下载尺寸大于 100 MB,您应该使用 Android 应用包 上传您的应用,该应用包允许压缩下载尺寸最大为 200 MB。此外,由于使用应用包将 APK 的生成和签名推迟到 Google Play,因此用户只会下载运行您的应用所需的代码和资源的优化 APK。您不必构建、签名和管理多个 APK 或扩展文件,用户会获得更小、更优化的下载内容。

概述

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

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

虽然您可以随意使用这两个扩展文件,但我们建议主扩展文件提供主要资源,并且很少需要更新;补丁扩展文件应该更小,并且充当“补丁载体”,在每次主要版本发布或需要时进行更新。

但是,即使您的应用程序更新只需要一个新的补丁扩展文件,您仍然必须上传一个带有更新的 versionCode 的新 APK 文件。(Play Console 不允许您将扩展文件上传到现有 APK 文件。)

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

文件名格式

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

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

该方案包含三个部分

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

强调“首次”是因为,虽然 Play Console 允许您将上传的扩展文件重新用于新的 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 从 Google Play 本身下载文件。

从高级别来看,下载过程如下所示

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

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

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

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

开发清单

以下是使用扩展文件与您的应用程序一起使用的任务摘要

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

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

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

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

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

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

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

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

完成应用程序开发后,请按照指南 测试您的扩展文件

规则和限制

添加 APK 扩展文件是在您使用 Play Console 上传应用程序时可用的功能。在首次上传应用程序或更新使用扩展文件的应用程序时,您必须了解以下规则和限制

  1. 每个扩展文件的大小不能超过 2 GB。
  2. 为了从 Google Play 下载您的扩展文件,用户必须从 Google Play 获得您的应用程序。如果应用程序是通过其他方式安装的,Google Play 将不会提供扩展文件的 URL。
  3. 在从应用程序内部执行下载时,Google Play 为每个文件提供的 URL 对于每次下载都是唯一的,并且每个 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 对于每次下载都是唯一的,并且每个 URL 在提供给您的应用后不久就会过期。

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

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

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

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

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

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

关于下载器库

要使用 APK 扩展文件与您的应用配合使用,并以最小的工作量提供最佳的用户体验,我们建议您使用 Google Play APK 扩展库软件包中包含的下载器库。该库在后台服务中下载您的扩展文件,显示带有下载状态的用户通知,处理网络连接丢失,在可能的情况下恢复下载等。

要使用下载器库实现扩展文件下载,您需要做的就是

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

以下各部分将说明如何使用下载器库设置您的应用。

准备使用下载器库

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

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

  • Google Play 授权库软件包
  • Google Play APK 扩展库软件包

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

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

注意: 下载器库依赖于授权验证库。确保将授权验证库添加到下载器库的项目属性中。

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

  1. 将目录更改为 <sdk>/tools/ 目录。
  2. 使用 --library 选项执行 android update project,将 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 Expansion 软件包包含一个示例应用,它展示了如何在应用中使用下载器库。该示例使用 Apk Expansion 软件包中提供的第三个库,称为 APK Expansion Zip 库。如果您计划使用 ZIP 文件作为扩展文件,建议您也将 APK Expansion Zip 库添加到您的应用中。有关更多信息,请参阅以下部分关于 使用 APK Expansion 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 Expansion Zip 库需要 API 级别 5。

实现下载器服务

为了在后台执行下载,下载器库提供了自己的 服务 子类,称为 DownloaderService,您应该扩展此子类。除了为您下载扩展文件之外,DownloaderService

  • 注册一个 广播接收器,该接收器侦听设备网络连接的变化(CONNECTIVITY_ACTION 广播),以便在必要时暂停下载(例如,由于连接丢失)并在可能时恢复下载(连接已建立)。
  • 安排一个 RTC_WAKEUP 闹钟,以便在服务意外停止的情况下重试下载。
  • 构建一个自定义 通知,用于显示下载进度和任何错误或状态变化。
  • 允许您的应用手动暂停和恢复下载。
  • 在下载扩展文件之前,验证共享存储是否已挂载并可用,文件是否不存在,以及是否有足够的存储空间。然后,如果任何一项不符合要求,则通知用户。

您需要做的就是创建扩展 DownloaderService 类的类,并覆盖三个方法来提供具体的应用详细信息

getPublicKey()
这必须返回一个字符串,该字符串是发布者帐户的 Base64 编码 RSA 公钥,可从 Play Console 的配置文件页面获得(请参阅 设置授权)。
getSALT()
这必须返回一个随机字节数组,授权 策略 使用该数组创建 Obfuscator。salt 确保用于保存授权数据的混淆 SharedPreferences 文件将是唯一的且不可发现的。
getAlarmReceiverClassName()
这必须返回应用中 广播接收器 的类名,该接收器应接收指示应重新启动下载的闹钟(如果下载器服务意外停止,可能会发生这种情况)。

例如,以下是 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,以调用 Downloader 库中的 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>

启动下载

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

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

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

    Downloader 库在 Helper 类中包含一些 API,可帮助您完成此过程。

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

    例如,Apk Expansion 包中提供的示例应用程序会在活动的 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,则 Downloader 库会开始下载,您应该更新活动 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() 方法中调用 CreateStub() 来实例化 IStub,在 startDownloadServiceIfRequired() 启动下载之后。

    例如,在之前的 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 交互的更新,您必须实现 Downloader 库的 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() 来请求每个状态的相应字符串。这会返回 Downloader 库捆绑的字符串之一的资源 ID。例如,字符串“下载已暂停,因为您处于漫游状态”对应于 STATE_PAUSED_ROAMING

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

提示:有关更新下载进度 UI 的这些回调的示例,请参阅 Apk Expansion 包中提供的示例应用程序中的 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 Downloader 库,则您仍然应该使用许可证验证库中提供的 APKExpansionPolicy。该 APKExpansionPolicy 类与 ServerManagedPolicy(在 Google Play 许可证验证库中可用)几乎相同,但包含了对 APK 扩展文件响应附加信息的额外处理。

注意:如果您确实使用了上一节中讨论的 Downloader 库,则该库会执行与 APKExpansionPolicy 的所有交互,因此您无需直接使用此类。

该类包含一些方法,可以帮助您获取有关可用扩展文件的必要信息。

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

有关在使用 Downloader 库 时如何使用 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 和所需的扩展文件版本来调用此方法。

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

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

使用 APK 扩展 ZIP 库

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

APK Expansion 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)
提供一个 AssetFileDescriptor 用于 ZIP 文件中的文件。 assetPath 必须是所需文件的路径,相对于 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 Expansion 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 开头。
    • 包名称始终与 APK 在 Google Play 上附加的文件的包名称匹配。
  3. 现在,扩展文件已在设备上,您可以安装并运行您的应用以测试您的扩展文件。

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

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

测试文件下载

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

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

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

更新您的应用

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

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

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

注意:即使您只需要对补丁扩展文件进行更改,您也必须更新 APK,以便 Google Play 执行更新。如果您不需要在应用中更改代码,您只需更新清单中的 versionCode

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

以下是一些关于更新扩展文件时需要牢记的问题。

  • 您的应用一次只能有两个扩展文件。一个主扩展文件和一个补丁扩展文件。在更新文件期间,Google Play 会删除以前的版本(因此您的应用在执行手动更新时也必须这样做)。
  • 添加补丁扩展文件时,Android 系统不会真正修补您的应用或主扩展文件。您必须设计您的应用以支持补丁数据。但是,Apk Expansion 包包含一个用于使用 ZIP 文件作为扩展文件的库,它将来自补丁文件的数据合并到主扩展文件中,以便您可以轻松地读取所有扩展文件数据。