分类目录归档:Android

如何优雅地应用 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 中的代码是这样的:

从上面的代码段中我们可以看出这货不会像 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 卡的读写权限是这么设置的:

所以在 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 机制流程图

CentOS 64 位机器配置 Android SDK 和 NDK 环境

做个小备忘。

因为工作上的一些需要,需要在 CentOS 64 位机器上搭建一个编译 Android 项目的环境,Android SDK 的环境还比较好搭建,直接下载 android-sdk 包即可,下载页面链接在这里 http://developer.android.com/sdk/index.html, 我下载的是 sdk only 包,体积比较小不包含 ADT 包(在服务器上用不着这些东西),因为是在服务器上,所以是木有任何桌面环境的,也就木有桌面浏览器啦。用 wget 或者 curl 下载即可。下载成功后,解压,可是目前目录下面只有 tools 目录,没有 platform-tools 目录,这个目录下的东西可不少哦,神马 aapt、dx 等等非常重要的工具都可是在这个里头哦,通常我们都是直接通过 android 命令就可以启动 Android SDK Manager 的界面管理工具,通过勾选不同平台就可以选择性地更新哪个版本的 sdk 了。服务器上木有 swt 环境啊(Android SDK Manager 是基于 SWT 实现的),肿么办捏?程序员做的东西肯定是有命令行界面的嘛。come 你的 on,google 一下吧,结果在 这里 ,万能的 google,程序员的福音 stackoverflow 上已经有人解决了该问题。大体就是通过

这个命令来查看有哪些 sdk 可以更新,你可以可以通过

来查看其他的一些选项,例如通过

来查看所有的可用的(包括 Android 认为已经过时的,例如 2.3.3 之类的各个 android 版本的 SDK,而且会将名字给你写出来哦),例如:

id: 61 or “extra-android-support”
Type: Extra
Desc: Android Support Library, revision 11
By Android
Install path: extras/android/support

这个指的是就是 Android 的 suppor-library,其他的各个名称也很直观啦,这些名字可以用的地方呢就是我们在使用
android update -u
命令进行 SDK 更新的时候可以通过设置 filter 来选择我们需要安装哪些包,例如下面这个命令:
android update sdk -u –filter extra-google-google_play_services
执行之后,就会选择 Google Play Service 这个包来下载更新,同理其他的都素一样的啦。例如通过
android update sdk -u –filter platform-tool
会自动下载 platform-tools 目录,你要是加上 platform,使用
android update sdk -u –filter platform-tool,platform
就会下载所有版本的 platform 文件,其他的命令自己一个个尝试就好了。等下载完成之后,配置 PATH 变量吧
export ANDROID_SDK_HOME=/root/android-sdk-linux
export PATH=$ANDROID_SDK_HOME/tools:$ANDROID_SDK_HOME/platform-tools:$PATH
配置完 SDK,那么开始配置 NDK 吧,毕竟很多时候我们在开发的过程中还是很有可能会使用到 NDK 的,那么开始吧,先下载 NDK 包吧,下载页面在 这里 ,选择对应平台下载链接下载吧,下载成功后解压缩之后,配置环境变量吧:
export ANDROID_NDK_HOME=/root/android-ndk-r8c
export PATH=$ANDROID_NDK_HOME:$PATH
配置好环境变量,加载一下环境变量,然后开始测试一下能否编译吧,cd 到 samples/hello-jni/jni 目录下,直接执行 ndk-build 命令,提示错误
./../../ndk-build: /root/android-ndk-r8c/prebuilt/linux-x86/bin/make: /lib/ld-linux.so.2: bad ELF interpreter: No such file or directory
提示缺少某个依赖文件,Google 之后得知,通过命令
yum install glibc.i686
装上 glibc 库即可,再次尝试 ndk-build,依然提示错误
/root/android-ndk-r8c/toolchains/arm-linux-androideabi-4.6/prebuilt/linux-x86/bin/../lib/gcc/arm-linux-androideabi/4.6/../../../../arm-linux-androideabi/bin/as: error while loading shared libraries: libz.so.1: cannot open shared object file: No such file or directory
提示缺少 zlib 库文件依赖文件,Google 之后得知,通过命令
yum install zlib.i686
装上 zlib 库文件之后,继续 ndk-build,这次能顺利编译出 so 文件了,也成功将文件拷贝到对应的 libs/armeabi 目录下了,可以有这么一个警告信息:
/root/android-ndk-r8c/toolchains/arm-linux-androideabi-4.6/prebuilt/linux-x86/bin/arm-linux-androideabi-strip: /lib/libz.so.1: no version information available (required by /root/android-ndk-r8c/toolchains/arm-linux-androideabi-4.6/prebuilt/linux-x86/bin/arm-linux-androideabi-strip)
大体的意思就时因为/lib 目录下的 libz.so.1 版本信息不对,导致 arm-linux-androideabi-strip 命令无法正常执行,这个会带来神马问题捏?我们来看看吧。其实就是/root/android-ndk-r8c/toolchains/arm-linux-androideabi-4.6/prebuilt/linux-x86/bin/下的 arm-linux-androideabi-strip 命令没有办法执行呗,那么这个命令是用来干嘛的,直接运行一下看看 usage:
Usage: /root/android-ndk-r8c/toolchains/arm-linux-androideabi-4.6/prebuilt/linux-x86/bin/arm-linux-androideabi-strip <option(s)> in-file(s)
Removes symbols and sections from files
大体的意思就是通过这个命令可以将我们编译出来的 so 文件中的一些不必要的符号和片段给移除掉,可以让这个二进制文件更紧凑一些,运行效率会高一些?这个我真不知道,因为自己对于编译和链接这一块儿懂得真是太少了所以也就没有什么发言权啦。闲话少说,既然看到有问题,当然这个 so 肯定是可以用的(因为 ndk-build 并没有出错,而且还将 so 文件 install 到 libs 下对应的 armeabi 目录下了,我想 Android 开发团队肯定不会坑爹地将一个可能出错的编译输出文件 install 到该目录下的,所以说这个问题应该不大,肯定可以正常运行),只是看到这个警告信息让我非常的不爽,或者移除了这些符号和片段会让你的二进制文件运行起来更爽,或者可以让别人无法通过反编你的 so 来查看你调用了什么函数之类的,具体原因不明,反正我就是不爽,我要让这个警告信息给我消失。怎么办呢?

肯定先 google 啦,google 了很多,看了一些文档,大体得出结论是这个 libz.so.1 的版本比较低,而 android 这个命令可能比较新,依赖的库的版本比当前我的机器上/lib 目录下的 libz.so.1 的版本要高,好吧,那就去下载源码自己编译吧。可以到这个 zlib 的官方页面 上的这个 下载地址(WTF,竟然需要翻墙)。下载后解压之后,cd 到 zlib 目录下,三步走
./configure
make
make install
整个过程很顺利,编译后的文件都被放到/usr/local/lib 下了,这个可以通过编译安装输出看出来
[root@helihua zlib-1.2.7]# make install
cp libz.a /usr/local/lib
chmod 644 /usr/local/lib/libz.a
cp libz.so.1.2.7 /usr/local/lib
chmod 755 /usr/local/lib/libz.so.1.2.7
cp zlib.3 /usr/local/share/man/man3
chmod 644 /usr/local/share/man/man3/zlib.3
cp zlib.pc /usr/local/lib/pkgconfig
chmod 644 /usr/local/lib/pkgconfig/zlib.pc
cp zlib.h zconf.h /usr/local/include
chmod 644 /usr/local/include/zlib.h /usr/local/include/zconf.h
然后将/lib/libz.so.1 文件先备份一下,将/usr/local/lib 目录下的 libz.so.1 链接到/lib 目录下,因为 arm-linux-androideabi-strip 这个命令链接的路径中依赖的/lib 目录下的 libz.so.1,当然我们也可以通过修改 LD_CLASSPATH 的方式来让 arm-linux-androideabi-strip 这个命令加载/usr/local/lib 目录下的 libz.so.1 文件,可惜我现在不想这么麻烦,因为我现在也不知道具体做法,我懒得去 Google 了,我的方法是直接将该文件链接到/lib 目录下,替换系统原有的 libz.so.1
mv /lib/libz.so.1 /lib/libz.so.1_backup
ln -s /usr/local/lib/libz.so.1 /lib/libz.so.1
但是童鞋们啊,这还是不够滴,因为现在的系统捏是尼玛 64 位滴,而该死的 Android 都是基于 32 开发的,它依赖的是 32 位的 libz,你现在执行 ndk-build 不出意外的话,你会碰到这个错误信息:
[root@helihua jni]# ../../../ndk-build
Gdbserver : [arm-linux-androideabi-4.6] libs/armeabi/gdbserver
Gdbsetup : libs/armeabi/gdb.setup
Install : libhello-jni.so => libs/armeabi/libhello-jni.so
/root/android-ndk-r8c/toolchains/arm-linux-androideabi-4.6/prebuilt/linux-x86/bin/arm-linux-androideabi-strip: error while loading shared libraries: libz.so.1: wrong ELF class: ELFCLASS64
make: *** [/root/android-ndk-r8c/samples/hello-jni/libs/armeabi/libhello-jni.so] Error 127
make: *** Deleting file `/root/android-ndk-r8c/samples/hello-jni/libs/armeabi/libhello-jni.so’
唉,那么就只好开始编译 32 位的咯,怎么编呢,还是 Google 吧,程序员福音 stackoverflow 上又有对应的问题,在 这里 ,通过
export CFLAGS=-m32
指定当前编译目标平台位 32 位滴,直接在 bash 下使用该命令只会在当前 bash 会话期间有效,不会影响到其他 bash 会话和下次你重新登录 bash 时的编译环境设置,非常有用,我们只需要在这次编译 libz 时才需要如此,正中下怀啊。果断重复上面的三步走啊:
./configure
make
make install
这次执行./configure 就不会那么顺利了,会出现这样一个错误:
./configure
Checking for gcc…
.
.
.
Looking for a four-byte integer type… Not found.
如果这个时候你选择继续 make 的话,显然会出错滴,错误如下:
/usr/include/gnu/stubs.h:7:27: error: gnu/stubs-32.h: No such file or directory
通过该命令:
yum install glibc-devel.i386
安装对应的开发环境,重新执行上面的三步,
成功后再重复设置/lib 目录的操作:
mv /lib/libz.so.1 /lib/libz.so.1_backup
ln -s /usr/local/lib/libz.so.1 /lib/libz.so.1
然后再尝试执行 ndk-build 命令,这次就不再提示任何警告和信息了,输出干净多了:
Gdbserver : [arm-linux-androideabi-4.6] libs/armeabi/gdbserver
Gdbsetup : libs/armeabi/gdb.setup
Install : libhello-jni.so => libs/armeabi/libhello-jni.so
回过头来再想想,为什么会出现这些问题呢?
前两个问题是因为 Android NDK 中的某些命令依赖于系统的一些库,没有直接安装就好了,这个没有过多讨论的余地,后面那个出现版本不一致导致的警告信息,我个人的理解是这样的,因为我们直接使用的都是 Android NDK prebuild 的命令,而这些命令可能在 Google 的编译服务器上依赖的是更高版本的库,所以导致在我们的环境中会出现版本不一致的问题。因为 NDK 中带着源码,我倒是觉得可以通过在自己的机器上编译一个 NDK 出来,应该就可以解决这个问题,当然前提是 NDK 的源码中没有引用到更高版本的一些头文件,另外编译源码可能需要我们安装很多的开发环境(就是很多的库和头文件),点到为止,有机会可以一试。

Ubuntu 下让 ADB 识别所有设备的两个方法

方法一,完全参考 谷歌的文档 ,内容如下
在/etc/udev/rules.d 目录下新建一个 51-android.rules 文件,将下面所有内容拷贝到该文件中:

方法二,有点 Hack 的味道,参考“ 孔雀的小屋 ”滴,就是将下面这行内容拷贝至/etc/udev/rules.d/51-android.rules 文件中,
通杀所有 Android 设备,上面的那些都可作为浮云

Android 中解决图像解码导致的 OOM 问题

在上一篇博文 Android Bitmap 内存限制 中我们详细的了解并分析了 Android 为什么会在 Decode Bitmap 的时候出现 OOM 错误,简单的讲就是 Android 在解码图片的时候使用了本地代码来完成解码的操作,但是使用的内存是堆里面的内存,而堆内存的大小是收 VM 实例可用内存大小的限制的,所以当应用程序可用内存已经无法再满足解码的需要时,Android 将抛出 OOM 错误。

这里讲一个题外话,也就是为何 Android 要限制每个应用程序的可用内存大小呢?其实这个问题可能有多方面的解答,目前我自己考虑到的有两点:

  1. 使得内存的使用更为合理,限制每个应用的可用内存上限,可以防止某些应用程序恶意或者无意使用过多的内存,而导致其他应用无法正常运行,我们众所周知的 Android 是有多进程的,如果一个进程 (也就是一个应用) 耗费过多的内存,其他的应用还搞毛呢?当然在这里其实是有一个例外,那就是如果你的应用使用了很多本地代码,在本地代码中创建对象解码图像是不会被计算到的,这是因为你使用本地方法创建的对象或者解码的图像使用的是本地堆的内存,跟系统是平级的,而我们通过 Framework 调用 BitmapFactory.decodeFile() 方法解码时,系统虽然也是调用本地代码来进行解码的,但是 Android Framework 在实现的时候,刻意地将这部分解码使用的内存从堆里面分配了而不是从本地堆里分配的内存,所以才会出现 OOM,当然并不是说从本地堆里分配就不会出现 OOM,本地堆分配内存超过系统可用内存限制的话,通常都是直接崩溃,什么错误可能都看不到,也许会有一些崩溃的错误字节码之类的。
  2. 省电的考虑,呃…,原因我好像也不能很明白地说出来。

回到正题来,我们在应用的设计和开发中可能会经常碰到需要在一个界面上显示数十张图片乃至上百张,当然限于手机屏幕的大小我们通常在设计中会使用类似于列表或者网格的控件来展示,也就是说通常一次需要显示出来图片数还是一个相对确定的数字,通常也不会太大。如果数目比较大的画,通常显示的控件自身尺寸就会比较小,这个时候可以采用缩略图策略。下面我们来看看如果避免出现 OOM 的错误,这个解决方案参考了 Android 示范程序 XML Adapters 中的 ImageDownloader.java 中的实现,主要是使用了一个二级缓存类似的机制, 就是有一个数据结构中直接持有解码成功的 Bitmap 对象引用,同时使用一个二级缓存数据结构持有解码成功的 Bitmap 对象的 SoftReference 对象,由于 SoftReference 对象的特殊性,系统会在需要内存的时候首先将 SoftReference 对象持有的对象释放掉,也就是说当 VM 发现可用内存比较少了需要触发 GC 的时候,就会优先将二级缓存中的 Bitmap 回收,而保有一级缓存中的 Bitmap 对象用于显示。

其实这个解决方案最为关键的一点是使用了一个比较合适的数据结构,那就是 LinkedHashMap 类型来进行一级缓存 Bitmap 的容器,由于 LinkedHashMap 的特殊性,我们可以控制其内部存储对象的个数并且将不再使用的对象从容器中移除,这就给二级缓存提供了可能性,我们可以在一级缓存中一直保存最近被访问到的 Bitmap 对象,而已经被访问过的图片在 LinkedHashMap 的容量超过我们预设值时将会把容器中存在时间最长的对象移除,这个时候我们可以将被移除出 LinkedHashMap 中的对象存放至二级缓存容器中,而二级缓存中对象的管理就交给系统来做了,当系统需要 GC 时就会首先回收二级缓存容器中的 Bitmap 对象了。在获取对象的时候先从一级缓存容器中查找,如果有对应对象并可用直接返回,如果没有的话从二级缓存中查找对应的 SoftReference 对象,判断 SoftReference 对象持有的 Bitmap 是否可用,可用直接返回,否则返回空。

主要的代码段如下:

private static final int HARD_CACHE_CAPACITY = 16;

// Hard cache, with a fixed maximum capacity and a life duration
private static final HashMap<String, Bitmap> sHardBitmapCache = new LinkedHashMap<String, Bitmap>(HARD_CACHE_CAPACITY / 2, 0.75f, true) {
    private static final long serialVersionUID = -57738079457331894L;

    @Override
    protected boolean removeEldestEntry(LinkedHashMap.Entry<String, Bitmap> eldest) {
        if (size() > HARD_CACHE_CAPACITY) {
            // Entries push-out of hard reference cache are transferred to soft reference cache
            sSoftBitmapCache.put(eldest.getKey(), new SoftReference<Bitmap>(eldest.getValue()));
            return true;
        } else
            return false;
    }
};

// Soft cache for bitmap kicked out of hard cache
private final static ConcurrentHashMap<String, SoftReference<Bitmap>> sSoftBitmapCache = new ConcurrentHashMap<String, SoftReference<Bitmap>>(HARD_CACHE_CAPACITY / 2);

/**
* @param id
*            The ID of the image that will be retrieved from the cache.
* @return The cached bitmap or null if it was not found.
*/
public Bitmap getBitmap(String id) {
    // First try the hard reference cache
    synchronized (sHardBitmapCache) {
        final Bitmap bitmap = sHardBitmapCache.get(id);
        if (bitmap != null) {
            // Bitmap found in hard cache
            // Move element to first position, so that it is removed last
            sHardBitmapCache.remove(id);
            sHardBitmapCache.put(id, bitmap);
            return bitmap;
        }
    }

    // Then try the soft reference cache
    SoftReference<Bitmap> bitmapReference = sSoftBitmapCache.get(id);
    if (bitmapReference != null) {
        final Bitmap bitmap = bitmapReference.get();
        if (bitmap != null) {
            // Bitmap found in soft cache
            return bitmap;
        } else {
            // Soft reference has been Garbage Collected
            sSoftBitmapCache.remove(id);
        }
    }

    return null;
}

public void putBitmap(String id, Bitmap bitmap) {
    synchronized (sHardBitmapCache) {
        if (sHardBitmapCache != null) {
            sHardBitmapCache.put(id, bitmap);
        }
    }
}

上面这段代码中使用了 id 来标识一个 Bitmap 对象,这个可能大家在实际的应用中可以选择不同的方式来索引 Bitmap 对象,图像的解码在这里就不做赘述了。这里主要讨论的就是如何管理 Bitmap 对象,使得在实际应用中不要轻易出现 OOM 错误,其实在这个解决方案中,HARD_CACHE_CAPACITY 的值就是一个经验值,而且这个跟每个应用中需要解码的图片的实际大小直接相关,如果图片偏大的话可能这个值还得调小,如果图片本身比较小的话可以适当的调大一些。本解决方案主要讨论的是一种双缓存结合使用 SoftReference 的机制,通过使用二级缓存和系统对 SoftReference 对象的回收特性,让系统自动回收不再敏感的图片 Bitmap 对象,而保有一级缓存也就是敏感的图片 Bitmap 对象。

Android PendingIntent 的一些小迷惑

近日在开发中刚好涉及到桌面 Widget 的一些开发工作,而桌面 Widget 控件的点击事件,通常只能通过 RemoteViews.setOnClickPendingIntent(int viewId, PendingIntent pendingIntent) 方法来指定响应的行为。

通常实际应用中我们会把桌面 Widget 作为应用的快捷方式和缩略展示,那么通常我们做的事情一般是点击桌面 Widget 上某控件后,跳转到对应的 Activity 中,那么我们就需要使用到 PendingIntent.getActivity(Context context, int requestCode, Intent intent, int flags) 来获取一个 PendingIntent 实例,通常我们会在 Intent 中指定我们的目标 Activity,并通过 putExtra 方法来传递一些必要的参数。例如:

Intent intent = new Intent(context, MainActivity.class);

intent.putExtra(“GREETING”,”HelloWorld”);

PendingIntent pendingIntent = PendingIntent.getActivity(context, 0,intent, 0);

remoteViews.setOnClickPendingIntent(R.id.widget_goto_main, pendingIntent);

上面的这个代码呢,主要目的就是能让桌面 Widget 上的控件响应点击事件,并且能直接进入 MainActivity。这里我们将 requestCode 和 flags 都设置为 0 了,目前 Android 中还没有使用到 requestCode 来做什么控制,只是预留了这么一个参数方便于未来的扩展,但是 Flag 能就非常有用了,因为系统会通过 Flag 来识别需要进行的行为。我么先来看一下官方文档上的一段话:

A PendingIntent itself is simply a reference to a token maintained by the system describing the original data used to retrieve it. This means that, even if its owning application’s process is killed, the PendingIntent itself will remain usable from other processes that have been given it. If the creating application later re-retrieves the same kind of PendingIntent (same operation, same Intent action, data, categories, and components, and same flags), it will receive a PendingIntent representing the same token if that is still valid, and can thus call cancel() to remove it.

我翻译一下:一个 PendingIntent 就是一个 Android 系统管理并持有的用于描述和获取原始数据的对象的标志 (引用)。这就意味着,即便创建该 PendingIntent 对象的进程被杀死了,这个 PendingItent 对象自己在其他进程中还是可用的。如果创建该 PendingIntent 对象的进程随后又重新获取了一个同类型的 PendingIntent(对于程序来讲,就是通过同样的方法获取的,例如都是通过 getActivity、getBroadcast、getService 方法来获取的,并且传递给 getXXX 方法的 Intent 对象的 Action 是相同的,Data 也是相同的,Categories 也是相同的,Components 也是相同的,Flags 也是相同的),如果之前获取的 PendingIntent 对象还有效的话,那么该进程获取到的 PendingItent 对象将获得同一个对象的引用,而且可以通过 cancel() 方法来从系统中移除它。

如果我们只是想通过设置不同的 Extra 来生成不同的 PendingIntent 对象是行不通的,因为 PendingIntent 对象由系统持有,并且系统只通过刚才在上面提到的几个要素来判断 PendingIntent 对象是否是相同的,那么如果我们想在每次更新 Widget 的时候也更新 PendingIntent 对象的话,我们应该怎么做的,目前我能想到的就是通过设置 Flag 的方式来做。

目前在 Android 中有以下 flag:

FLAG_CANCEL_CURRENT: 如果当前系统中已经存在一个相同的 PendingIntent 对象,那么就将先将已有的 PendingIntent 取消,然后重新生成一个 PendingIntent 对象。

FLAG_NO_CREATE: 如果当前系统中不存在相同的 PendingIntent 对象,系统将不会创建该 PendingIntent 对象而是直接返回 null。

FLAG_ONE_SHOT: 该 PendingIntent 只作用一次,如果该 PendingIntent 对象已经触发过一次,那么下次再获取该 PendingIntent 并且再触发时,系统将会返回一个 SendIntentException,在使用这个标志的时候一定要注意哦。

FLAG_UPDATE_CURRENT: 如果系统中已存在该 PendingIntent 对象,那么系统将保留该 PendingIntent 对象,但是会使用新的 Intent 来更新之前 PendingIntent 中的 Intent 对象数据,例如更新 Intent 中的 Extras。这个非常有用,例如之前提到的,我们需要在每次更新之后更新 Intent 中的 Extras 数据,达到在不同时机传递给 MainActivity 不同的参数,实现不同的效果。

大概就是这么多了,:-),其实自己也还是有点晕,以后有工夫了再慢慢补充吧,先做个记录也好!整个四月份都没有发过博客了,在四月份和五月份交接的此时此刻,发布一篇,以做纪念吧,:-)。