标签归档:obb

如何优雅地应用 Google Play Obb 机制

先说点闲话

近期我们的游戏 SailCraft 准备上线一个新的版本,在该版本中我们采用了 APK + Obb 文件的方式来实现资源的分发,尽可能减少玩家直接通过 CDN 下载游戏资源文件的概率。这是基于前一段时间我们游戏在 Google Play 中获得了一周的新游推荐之后,从玩家注册转化率数据上发现了在部分地区的玩家转化率明显不正常,跟踪分析之后发现是由于我们客户端的一个错误实现 + 玩家所在地区网络带宽太小这两个原因,致使玩家无法顺畅地进入游戏的事实得出来的判断。由于我们在海外 Android 的分发完全依赖 Google Play,这样就意味着我们所有的用户都能顺畅地从 Google Play 下载安装游戏,依托 Google Play 在全球的网络优化,我们完全可以不用考虑安装包分发的问题。由于时间的原因和我个人在 Obb 上的一些不太愉快的经历,之前确定资源文件分发方案的时候,我们过于理想化地认为只要我们选择最好的 CDN 厂商,应该就能解决我们的问题,所以最终我们在上线时并未考虑使用 Google Play 提供的 Obb 文件机制。

最终事实告诉我们 Google Play 在全球的网络优化和分发能力远不是我们这种小团队可以想象的,而且使用 Obb 文件还有一个额外的好处就是可以大量减少我们使用的 CDN 服务的流量费用。经历了这次 Google Play 全球新游推荐,让我们更多地接触到了平时可能触及不到的一些地区的玩家,也帮我们收集了更多数据更是直接反映出来了很多问题,其中之一就是使用 Obb 文件的必要性。既然箭已在弦上,那就不得不搞了。我们选择的做法是,直接将未打包在 APK 里头的所有资源打包成 Zip 包,作为 Obb 文件上传到 Google Play 后台,下载成功后首次启动游戏时,将 Zip 中文件解压存放到游戏专属的 SD 卡的 data 子目录中,后续的资源更新也是直接将更新资源下载到该 data 子目录中。

确定方案

那么我们先来梳理一下 Google Play 是如何处理 Obb 文件的,先来看看我们为什么要用 Obb 文件:

  1. Google Play 目前对于 API 等级 9 以上的 APK 支持最大文件大小为 100M,对于 API 等级为 8 及以下的 APK 文件大小限制为 50M(那种设备对于我们游戏开发厂商来讲毫无价值,我们也不会支持)。
  2. 目前绝大部分游戏厂商的产品最终的文件大小都是超过 100M 的,我们目前线上的版本完整包的大小时 130M,所以我们的做法是将 APK 大小控制在 95M 左右,将其他的 40M 大小的资源文件打包成 Zip 包作为 Obb 文件进行上传。

接下来我们得确定我们的 Obb 文件究竟是以什么样的形式呈现,以及我们游戏又该如何跟 Obb 文件进行交互。

由于 Android 系统自身实际上对于 Obb 文件并没有什么实质上的规范和要求,甚至我们可以理解为这只是 Google 在 Google Play 这个服务的基础上提供的一个解决方案而已,并非 Android 系统的一个基础设施,本质上 Android 系统只是将 Obb 文件以及其存放的目录当作普通的文件和目录来处理罢了,所以我们选择直接将所有的资源文件打包成 Zip 包进行上传作为 Obb 文件。Google Play 支持一个 APK 最多可以上传两个 Obb 文件,一个 Main Obb,一个 Patch Obb,每个 Obb 文件的大小上限为 2G,通常 Main Obb 不经常修改建议可以大一些,更新最好以 Patch Obb 文件的形式上传,这个文件可以小一些,在后续版本更新中可以持续更新这个文件。

目前我们暂时不考虑使用双 Obb 文件的方法了,毕竟当我们需要更新版本的时候,通常都需要更新 APK 包,而 APK 包的大小已经接近了 100M,玩家通常也会选择在有 Wi-Fi 网络的情况下进行安装包的更新,所以如果我们真的有必要更新 Obb 文件时,选择跟版本更新同时更新 Main Obb 文件问题应该不大。不过我们确实可以将 Patch Obb 用来做资源的更新,只是这样对整个资源打包流程的改动较大,特别是制作 Patch Obb 的流程会变得很复杂,对于代码的处理逻辑来说也相对来说会更复杂一些,还需要考虑到线上资源更新与 Patch Obb 资源更新之间如何取舍的问题,在第一个版本中可以暂时不考虑,后续可以再完善。

既然这样的话,那么当我们从 Google Play 中下载安装游戏时,Google Play 会先在 /sdcard/Android/obb 目录下创建一个以游戏包名(例如:com.seabattle.uq)命名的目录,然后将我们上传到后台的 Obb 文件下载到这个目录下。首次启动游戏时,我们可以直接将该 Obb 文件当作 Zip 包进行解压,将解压的资源文件保存到 /sdcard/Android/data/com.seabattle.uq/ 目录下,游戏内读取资源只从 APK 包内部和该目录进行读取即可。如果后续有某个资源文件需要进行更新,可以直接从 CDN 上下载,将更新的资源文件也保存到该目录即可。

开始编码

确认好了具体的方案之后,我们就可以开始编码了。看上去我们需要做的调整并不多,目前看来只需要做几件事情,我们就可以享受 Obb 机制带来的巨大利好了,对伐?

  1. 把原来需要从 CDN 下载的资源文件整理好压缩成一个 Zip 包,在上传 APK 到 Google Play 后台时,将其作为 Obb 文件同时上传;
  2. 在游戏首次启动的时候判断一下是否成功解压过 Obb 文件,如果没有解压过 Obb 文件,就直接从 /sdcard/Android/obb/com.seabattle.uq/ 目录下找到我们需要解压的 Obb 文件,直接使用相应的 Zip 库将文件解压到 /sdcard/Android/data/com.seabattle.uq/ 目录下,然后直接走正常加载资源进入游戏的流程就好了。

当然上面的两步是一个基础,也是一定要做的,但是只做这两步的话还是远远不够的,我们来看看会有哪些问题。

由于 Android 系统相对开放,大家随时都可能通过各种手段访问和操作 SD 卡,而 Obb 文件就是存在在 SD 卡上的,也就是说在游戏 App 依然安装在设备上的同时,Obb 文件是否可用是没有绝对保证的,主要有以下几种可能导致 Obb 文件不存在:

  1. Google Play 在下载安装 APK 的时候,未能成功地将 Obb 文件下载下来,这个 Google 官方的说法是这样的:Expansion files are hosted at no additional cost. When possible, Google Play downloads expansion files when apps are installed or updated. In some cases, your app will need to download its expansion files.
  2. 玩家不小心错误地将游戏对应的 obb 目录下的 Obb 文件给删除了(注意这里只考虑文件被删除的情况,因为目录被删除了的话就是另外一种情况了,而且目录被删除的话会更麻烦)。

那这个时候玩家的设备上都没有 Obb 文件,我们怎么办,解压个鬼啊?别担心,Google 自己也是考虑到了这些情况的,所以 Google 官方是有提供一个完整的 Obb 文件下载解决方案的,就是为了让大家可以快速地集成一个手动从 Google Play 下载 Obb 文件的服务到我们已有的项目里头来的,代码就在[ANDROID_SDK_PATH]/extras/google/目录下,分别是以下三个目录:

  1. market_apk_expansion/downloader_library
  2. market_apk_expansion/zip_file
  3. market_licensing

将这三个工程作为 Library Project 导入到 Android 工程中就可以直接使用了,具体如何调用这个服务可以参考market_apk_expansion/downloader_sample中的实现。

不过在集成这几个 Library 到项目里时有几个地方需要注意一下:

  1. Google 提供的这个解决方案实现的时代已经很是久远了,而目前已经疏于维护了,所以这个解决方案中的三个 Library 工程的编译 SDK 等级都只能设置为 15,过高的话可能会出现某些工程中引用的 API 已经在高版本的 SDK 中被移除了导致无法编译的问题;
  2. 这个解决方案中使用的 Notification 相关的代码实在太老了,在运行时会直接输出错误级别的日志,最好时引入一个 Support 库,然后通过 NotificationCompat 的方式来替换那些古老的实现;

所以我们集成这个 Obb 下载服务到咱们游戏内的目的就是为了解决玩家首次启动游戏时,设备 SD 卡上游戏专属的 obb 目录下的 obb 文件不存在或者未下载成功的问题,虽然可能用到的概率很小,但是为了玩家,上吧。当我们把 Obb 文件丢失或者下载不成功的骨头啃完了以后,是不是就可以歇歇了呢?

意料之外的“惊喜”(坑)

我想你也看到了我在上文提到了一个 /sdcard/Android/obb/com.seabattle.uq/ 目录都被删除的情况了对吧,这个情况就更加复杂和麻烦了,为了要应对这种情况,如果我们的游戏没有申请读写 SD 卡的权限的话,我们会想到以下的一些可能的解决方法:

  1. 提示玩家,“对不起,你 SD 卡上的这个 obb 目录不见了,我们啥也干不了,请卸载我们的游戏,然后重新从 Google Play 里头下载安装一遍吧”,你觉得玩家会怎么说?“草泥马,卸载”。
  2. 是否可以尝试调用 Google 提供的这个 Obb 下载的服务去重新下载 Obb 文件呢?对不起,不可以,因为咱们没有申请读写 SD 卡的权限,所以目录被删除了之后,Obb 下载服务自己也是没有创建目录的权限的,因为这个服务就是集成在游戏内的,游戏申请了哪些权限决定了游戏内所有的接口调用的权限,所以死了这条心吧,在 obb 目录被删除之后,重试调用 ObbDownloadService 去下载 Obb 文件会直接失败的。
  3. 那么我们总不能真的回到方法 1 吧,也不能不让玩家玩游戏啊。那么此时我们就只能选择直接从 CDN 处下载资源文件了,下载成功后就可以进入游戏了。

那我们吭哧吭哧地把代码写好了,包也打出来了,测试一下吧。你等着,还有更多惊喜在等着你哦。

在我们将打包的 APK 包和 Obb 文件上传到 Google Play 后台,并且发布到 Beta 版本后,我们使用测试帐号进行了下载安装测试,发现了一个比较诡异的问题,那就是在某些手机上(Samsung S7, S7 Edge,红米 Note,乐视 Max 等),首次启动的时候,游戏并没有主动去解压已经成功下载到 /sdcard/Android/obb/com.seabattle.uq/ 目录下的 main.25.com.seabattle.uq.obb 文件,而是选择了直接从 CDN 处下载资源文件。但是在某些手机上(Google Nexus 6)一切正常,卸载重试安装多次,都是正常的。

继续研究

最终通过查看日志,发现在出现问题的这些设备上,游戏首次启动的时候,对 /sdcard/Android/obb/com.seabattle.uq/ 目录就没有读取的权限,所以客户端的逻辑判断认为 Obb 文件不存在,然后就启动了 Obb 下载服务去下载 Obb 文件了,但是也不知道为啥这个 Obb 下载服务竟然能判断出来 main.25.com.seabattle.uq.obb 文件已经下载成功了,不需要再进行下载了,然后就直接回调了下载 Obb 文件成功的逻辑,然而实际上它压根儿就没有权限访问 main.25.com.seabattle.uq.obb 这个文件。

针对这个 Obb 下载服务能正确判断 Obb 文件存在,但是我们却无法访问的问题,今天细细探究了一番,最终发现 Android 中对 File.exists 方法的实现跟 JDK 中的实现是有区别的,在 Android 中的代码是这样的:

public boolean exists() {
    return doAccess(F_OK);
}

private boolean doAccess(int mode) {
    try {
        return Libcore.os.access(path, mode);
    } catch (ErrnoException errnoException) {
        return false;
    }
}

从上面的代码段中我们可以看出这货不会像 JDK 中抛出 SecurityException 这样的运行时错误,我们可以看看 JDK 中的访问文档是怎么样的:

public boolean exists()

Tests whether the file or directory denoted by this abstract pathname exists.

Returns:

true if and only if the file or directory denoted by this abstract pathname exists; false otherwise

Throws:

SecurityException – If a security manager exists and its SecurityManager.checkRead(java.lang.String) method denies read access to the file or directory

那么这个 F_OK 是个什么鬼啊,这就要去看 Linux 中 access(2) 的文档了,因为 Android 实际上最终调用的就是这个方法。

access() checks whether the calling process can access the file
pathname. If pathname is a symbolic link, it is dereferenced.

The mode specifies the accessibility check(s) to be performed, and is
either the value F_OK, or a mask consisting of the bitwise OR of one
or more of R_OK, W_OK, and X_OK. F_OK tests for the existence of the
file. R_OK, W_OK, and X_OK test whether the file exists and grants
read, write, and execute permissions, respectively.

由此我们可以得出结论了,由于 Android 中 File.exists() 方法判断一个文件是否存在并不需要调用者有对该文件的任何访问权限,这个方法只是判断文件是否真实存在,由于该文件确实是存在的,所以即便调用者对于该文件没有任何访问权限都会返回 true,致使 Obb 下载服务确实可以正确的判断 Obb 文件成功,而我们的代码逻辑选择了直接信任 Obb 下载服务返回的结果,认为只要返回下载成功就可以直接访问该 Obb 文件了,但是后续我们尝试通过其他的接口来列举 obb 目录下的文件清单和单独访问该文件都会出现权限异常的问题。

客户端在下载 Obb 文件成功的回调中,会主动调用解压 Zip 包文件的逻辑,但是由于最终无法访问 /sdcard/Android/obb/com.seabattle.uq/ 目录和 /sdcard/Android/obb/com.seabattle.uq/main.25.com.seabattle.uq.obb 文件,导致解压 Obb 文件的流程失败了,然后就只能走 CDN 下载的流程了。

我们可以看看这个截图,这是从 Google Play 下载安装成功游戏后,游戏专属的 obb 目录的权限的截图:

安装游戏成功后obb目录权限截图

这个问题看上去这么诡异,我们放狗搜一下吧,看看有没有人也遇到了类似的问题,不搜不知道,一搜吓一跳。原来在 Android 官方的 Issue Tracker 中已经有了两个跟我们一模一样的问题,而且别人还找到了怎么让这个问题自动修复好的办法,那就是重启手机。这两个问题的链接在这里:
https://issuetracker.google.com/issues/37544273https://issuetracker.google.com/issues/37075181

那么重启一下手机之后,游戏专属的 obb 目录的权限会变成什么样呢:

重启手机后正确的obb目录权限截图

至此我们已经可以很确定地说,在大部分的 Android 设备上会存在这个 obb 目录权限出错的问题,这会导致游戏在不申请读写 SD 卡权限的情况下,在这些设备上无法正常地读取已经从 Google Play 上下载成功的 Obb 文件。由于我们游戏目前对于 SD 卡的读写权限是这么设置的:

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

所以在 Android 5.0 以上的设备上,实际上我们游戏是只能读写 SD 卡上专属的两个目录,/sdcard/Android/obb/com.seabattle.uq/ 和 /sdcard/Android/data/com.seabattle.uq/,但是由于 Android 或者说是 Google Play 的这个未能正确设置 obb 目录权限的问题,我们就无法在 SDK 等级为 18 以上的设备上正确地读取并解压 Obb 文件。这下蛋疼了,尼玛做了这么多,你告诉我,这都白干了?

这个时候我们就只能根据实际情况来做判断了,鉴于 Android 设备的各种奇怪设定我们已经见怪不怪了,而且我们也看到了在 Android 的 Issue Tracker 中其他开发者提到的受影响的设备和 Android 版本的情况,所以我们可以初步判断这个问题可能影响到的设备数量级较大,而且目前并没有什么更好的解决方案,如果我们想利用 Google Play 提供的 Obb 文件带来的益处,就只能考虑申请 SD 卡读写权限了。

最终的选择和方案

基于这个问题,我们也请教了腾讯游戏的开发者,沟通的过程中得知腾讯所有发海外的游戏通常都会主动申请 SD 卡读写权限,他们貌似都没有遇到过我们这个问题。好吧,既然这样,那我们多看看其他的厂商是如何处理的吧。

  1. 《炉石传说》,未使用 Obb 文件,游戏启动之后直接从 CDN 下载资源文件,但是下载速度惊人的快且稳定;
  2. 《游戏王》,未使用 Obb 文件,游戏启动之后直接从 CDN 下载资源文件,下载文件之多,简直惊人,速度也较快且稳定;
  3. 《剑与家园》,使用 Obb 文件,游戏启动后主动申请 SD 卡读写权限,获得权限后解压 Obb 资源;

这么看来一线大厂们基于自己多年分发游戏的积累,已经形成了一套非常稳定可靠的资源更新下载的机制,可以不依赖单个分发平台提供的便利机制,而使用自有的资源分发机制,这样可以降低项目的复杂性和不同平台上维护的难度,不失为一种可行的方案。但是对于中小厂商,由于在全球发行上并未积累太多的经验,很大的程度上选择依托 Google Play 这样成熟的平台会更有优势,所以此时可能只能做退一步的选择了,那就是为了尽可能利用 Google Play 提供的 Obb 文件分发机制减少使用 CDN 可能带来的下载问题和流量费用,但是鉴于目前可能存在的 obb 目录读取权限的问题,在目录出现访问权限的问题时主动申请权限,如果在某些设备上刚好运气不错可以直接访问 obb 目录下的内容的话,就可以直接进行解压了,不需要动态申请该权限了。至于这会影响到多少玩家因为游戏主动申请 SD 卡权限而选择放弃这款游戏或者去 Google Play 中给一个差评,这就很难讲了。作为技术执行者,我们能做到的这已经是极致了。

最终整个方案的处理流程图如下:

Android Google Play Obb 机制流程图