前言
- 一般情况下,Android开发者应该通过各种有效途径来减小生成的Apk大小,比如移除无效资源文件、只保留xxhdpi资源、离线懒加载非必要资源等。
- 特殊情况下,出于对用户体验的考虑,一些依赖高清无损资源的App可能会生成几百M甚至1G以上的安装包,国内的分发平台对安装包的大小没有强制规定,但是对于出海产品来说,Google Play并不允许开发者上传超过100M的安装包。
- 针对以上问题,Google官方提供了Apk Expansion Files,支持开发者构建超过100M的安装包。
概念
首先,对于传统的Android开发领域来说,分包指的是MultiDex,即将单个dex拆分成多个以突破函数数目瓶颈的技术。而这里的分包(Apk Expansion Files)指的是将Apk文件和大容量的资源文件分开打包,大容量的资源文件包括高清大图,音频文件,视频文件等,这些文件最终都会压缩到统一的.obb文件里。注意,抽出到obb的内容不包括运行时代码。所以开发者需要保证在缺少.obb文件的情况下,程序依然能正常运行(不会Crash)。
在分包之前,开发者需要明确项目中的大容量资源文件究竟是什么,大多数情况下,他们指的是assets目录下的资源以及raw下的文件,如果drawable和mipmap目录下有超过1M的文件,也可以考虑将其进行分包处理,这种情况下需要开发者将该资源的引用方式从直接使用资源id:R.drawable.xxx改为从文件中解析。
所有的资源文件将被压缩为obb文件,最终上传到GooglePlay供用户下载。
obb文件
-
概念
什么是obb文件,obb全称是Opaque Binary Blob,翻译过来是不透明的二进制对象,再进一步解析就是具有访问权限的二进制文件。看到这个定义很容易联想到另外一种文件格式——zip压缩包文件。所以,从本质上来说,obb文件和zip文件是一样的,它们只是在不同领域上不同解释罢了。而在Android分包领域,obb还有自己的一些规则。
-
命名规则
obb的命名规则如下:
[main/patch].[versionCode].[packageName].obb
- 第一部分由可选字段组成,只能填入main或者patch,main指的是主扩展文件,而patch是对于main的补丁或扩展。第一次分包时填入main,而后续如果只是对分包进行增量修改的话,填入patch。笔者习惯每次发版都将所有资源重新打包成obb文件,所以只使用main字段。
- 第二部分为当前app的 versionCode,当确定好这次发版的versionCode后,大胆填入即可。
- 第三部分为 packageName,可在AndroidManifest.xml的根节点中读取package字段得到。
- 最后记得加上obb文件后缀名。
- 这里举个例子:
main.16.com.example.obbtest.obb
-
生成方法
-
方法一:
官方工具法,Google官方提供了Jobb工具用来生成obb文件,工具可以在 Android\sdk\tools\bin文件夹下找到。这是一个命令行工具,具体用法和参数如下:
$ jobb -d [所有资源的路径] -o [生成的obb名称(请遵循上述命名规则)] -k [打包密码] -pn [包名] -pv [versionCode(跟obb名称的versionCode一致)]
也可以使用该工具对obb文件进行解压:
$ jobb -d [输出路径] -o [obb文件名] -k [打包所用的密码]
-
方法二:
压缩工具法,直接使用Windows或者Mac上的打包工具,将文件压缩成zip包后,更改文件名即可。
需要注意的是,压缩文件格式需要选择zip,并将压缩方式改为存储。如需进行加密,可使用压缩工具自带的设置密码方法,得到的效果和官方方法设置 -k 参数是一样的。
压缩完后别忘了将文件名改为符合命名规范的obb文件名,如:main.16.com.example.obbtest.obb
-
方法三:
gradle打包法,即通过在build.gradle中添加压缩脚本的方式,将需要打入obb的资源集体打包的方法。该方法会在后文中进行详细介绍。
-
上传obb测试
-
本地测试
本地测试的原理是模仿Google Play下载,将obb文件复制到相应的目录。通过Google Play下载的obb文件存放的路径为:/Android/obb/App包名/
所以,通过在/Android/obb/下创建[app包名 如com.example.obbtest]文件夹,并将obb文件复制到该目录下即可模拟Google Play安装App。
-
线上测试
登录Google Play Console开发者账号,打开应用列表,选择需要测试的App:
-
左边控制栏选择 Release managerment ,然后选择 App Release,最后选择Internal test 的MANAGE INTERNAL TEST发布内部测试版本。
-
在内部测试里创建新的发布版本:将GooglePlay版本的Apk上传,上传完毕后,点击Apk右侧添加更多按钮,将obb文件提交上去,注意obb文件的命名版本号必须与上传的apk的版本号一致,否则会收到提交版本失败的错误。推荐大家使用不可能用在线上版本的versionCode进行测试,比如手机号码、女朋友生日等,以免后续提交正式版本时版本号被占用(不知道为什么GooglePlay的内部测试和正式发布的版本号竟然不能重复)。
填写剩下内容并发。,回到内部测试管理界面,选择管理测试者,将需要测试的Google账号提交上去,并将“Opt-in URL”的地址复制下来。
在测试机上登录测试账号,在浏览器里打开刚刚的“Opt-in URL”地址,即可加入内测,并可以通过Google Play App下载测试版本的App。
下载完成后,可以在/Android/obb/App包名/下看到一份崭新的obb文件。
解压和下载
-
解压
第一次安装完app后,需要将obb文件进行解压并将解压后的文件存储到我们定义的文件夹里(可以是data/data/包名/files/也可以是内置存储下自定义的项目文件夹)。要想解压obb文件,第一步是获取obb文件的本地路径,具体代码如下:
public static String getObbFilePath(Context context) { try { return Environment.getExternalStorageDirectory().getAbsolutePath() + "/Android/obb/" + context.getPackageName() + File.separator + "main." + context.getPackageManager().getPackageInfo(context.getPackageName(), 0).versionCode + "." + context.getPackageName() + ".obb"; } catch (PackageManager.NameNotFoundException e) { e.printStackTrace(); return null; } }
拿到obb文件路径后,可以开始进行解压了:
public static void unZipObb(Context context) { String obbFilePath = getObbFilePath(context); if (obbFilePath == null) { return; } else { File obbFile = new File(obbFilePath); if (!obbFile.exists()) { //下载obb文件 } else { File outputFolder = new File("yourOutputFilePath"); if (!outputFolder.exists()) { //目录未创建 没有解压过 outputFolder.mkdirs(); unZip(obbFile, outputFolder.getAbsolutePath()); } else { //目录已创建 判断是否解压过 if (outputFolder.listFiles() == null) { //解压过的文件被删除 unZip(obbFile, outputFolder.getAbsolutePath()); }else { //此处可添加文件对比逻辑 } } } } }
谷歌官方有提供解压obb文件的库供开发者使用,叫做APK Expansion Zip Library,感兴趣的小伙伴可以在一下路径下查看。
<sdk>/extras/google/google_market_apk_expansion/zip_file/
笔者不推荐使用该库,原因是这个库已经编写了有一些年头了,当时编译的sdk版本比较低,有一些兼容性的bug需要开发者修改代码后才能使用。所以这里使用的upzip方法是用最普通的ZipInputStream和FileOutputStream解压zip包的方式来实现的:
//这里没有添加解压密码逻辑,小伙伴们可以自己修改添加以下 public static void unzip(File zipFile, String outPathString) throws IOException { FileUtils.createDirectoryIfNeeded(outPathString); ZipInputStream inZip = new ZipInputStream(new FileInputStream(zipFile)); ZipEntry zipEntry; String szName; while ((zipEntry = inZip.getNextEntry()) != null) { szName = zipEntry.getName(); if (zipEntry.isDirectory()) { szName = szName.substring(0, szName.length() - 1); File folder = new File(outPathString + File.separator + szName); folder.mkdirs(); } else { File file = new File(outPathString + File.separator + szName); FileUtils.createDirectoryIfNeeded(file.getParent()); file.createNewFile(); FileOutputStream out = new FileOutputStream(file); int len; byte[] buffer = new byte[1024]; while ((len = inZip.read(buffer)) != -1) { out.write(buffer, 0, len); out.flush(); } out.close(); } } inZip.close(); } public static String createDirectoryIfNeeded(String folderPath) { File folder = new File(folderPath); if (!folder.exists() || !folder.isDirectory()) { folder.mkdirs(); } return folderPath; }
解压完成后,就可以通过输出文件的路径来访问到我们需要访问的大容量资源了,文件的读取在这里就不展开了。
-
下载obb
从Google Play下载和安装App有一定概率会下载到不包含obb文件的apk,或者obb文件被人为删除了。这种情况下,需要开发者到谷歌提供的下载地址处下载相应的obb文件。可是要怎么获取到下载地址呢,这里使用了官方的Downloader Library。
这个库可以通过Android Sdk Manager下载到,打开manager后勾上Google Play Licensing Library package和Google Play APK Expansion Library package点下载即可。可是在我兴高采烈准备大干一场的时候,发现它竟然编译不过[捂脸]。这个库和上面说的APK Expansion Zip Library一样,由于年代久远又年久失修,基本不能使用了。折腾了一些时间后,魔改了一个版本,才终于可以使用。 这里提供一个编译好的jar包google_apk_expand_helper。具体代码如下:
//随机byte数组,随便填就好 private static final byte[] salt = new byte[]{18, 22, -31, -11, -54, 18, -101, -32, 43, 2, -8, -4, 9, 5, -106, -17, 33, 44, 3, 1}; private static final String TAG = "Obb"; public static void getObbUrl(Context context, String publicKey) { final APKExpansionPolicy aep = new APKExpansionPolicy( context, new AESObfuscator(salt, context.getPackageName(), Settings.Secure.getString(context.getContentResolver(), Settings.Secure.ANDROID_ID) )); aep.resetPolicy(); final LicenseChecker checker = new LicenseChecker(context, aep, publicKey); checker.checkAccess(new LicenseCheckerCallback() { @Override public void allow(int reason) { Log.i(TAG, "allow:" + reason); if (aep.getExpansionURLCount() > 0) { //这里就是获取到的地址 String url = aep.getExpansionURL(0); } } @Override public void dontAllow(int reason) { Log.i(TAG, "dontAllow:" + reason); } @Override public void applicationError(int errorCode) { Log.i(TAG, "applicationError:" + errorCode); } }); }
上述方法中需要提供参数publicKey,这个publicKey可以在GooglePlayConsole中找到。
-
小结
掌握了上述的方法我们就已经完成了Apk分包的主要流程了,以下内容将举例说明如果通过配置gradle文件进行多渠道打包,如何在每次打包的时候自动将大容量资源文件压缩成obb等。
多渠道与自动化
-
例子
假设我们现在需要发布一个超过100M的安装包到GooglePlay以及应用宝,对于GooglePlay来说,我们需要生成小于100M的apk文件和obb文件,而对于应用宝来说,只需要生成一个完整的apk即可。
那么问题来了,我们不可能说在打包GooglePlay的时候将资源文件手动移除并修改资源引用的相关逻辑,然后再在打包应用宝的时候将他们放回来,这样做会大大增加开发者的工作量并且增大出错的可能性。那有没有办法在单个工程项目下既能打包GooglePlay的包又可以打包应用宝的包呢?答案是有的,build.gradle中的sourceSets就可以解决这样的问题。
-
利用sourceSets隔离渠道资源和资源引用代码
假设我们有一个splash.mp4文件,在应用宝中渠道包中,它被放在了res/raw/目录下。而在googlePlay渠道包中,它被放置在obb文件里,我们可以这么处理。
首先,在src目录下创建两个新的目录googlePlay和tencent,并在他们的目录下新建java,res和assest文件夹。
在app级别的build.gradle文件中添加GooglePlay和应用宝的渠道信息:
android { flavorDimensions "default" productFlavors { GooglePlay { dimension "default" } Tencent { dimension "default" } /** 在AndroidManifest.xml中加入 <meta-data android:name="Channel" android:value="${CHANNEL_NAME}" /> **/ productFlavors.all { flavor -> flavor.manifestPlaceholders = [CHANNEL_NAME: name] } } }
紧接其后添加sourceSets配置,指定不同渠道的资源和代码地址,其中main为共有资源和代码,其余的为对应渠道包的资源和代码:
sourceSets { main { java.srcDirs = ['src/main/java'] assets.srcDirs = ['src/main/assets'] res.srcDirs = ['src/main/res'] } GooglePlay { java.srcDirs = ['src/googlePlay/java'] res.srcDirs = ['src/googlePlay/res'] assets.srcDirs = ['src/googlePlay/assets'] } Tencent { java.srcDirs = ['src/tencent/java'] res.srcDirs = ['src/tencent/res'] assets.srcDirs = ['src/tencent/assets'] } }
将splash.mp4放到tencent/res/raw/文件夹下,并为不同渠道的java文件夹新建包名文件夹以及ResourcesHelper.java,完成后的目录结构如下:
有两点需要注意的地方:
一是java包下必须创建包名文件夹,否则会无法引用到项目下的类。该例子中就是com.example.obbtest包。
二是AndroidStudio中可以通过左下角的Build Variants窗口选择当前需要编译的渠道包类型,当选择GooglePlay时会发现tencent下的java文件失效了。所以,如果需要修改某渠道下的java文件,请先通过Build Variants切换到指定渠道。
最后,针对不同渠道的ResourcesHelper.java采用不同的资源获取方式:
GooglePlay版本:
public class ResourcesHelper { public static void playSplashVideoResource(VideoView videoView){ String filePath = ObbHelper.getCurrentObbFileFolder()+"raw/"+"splash.mp4"; videoView.setVideoPath(filePath); } }
tencent版本:
public class ResourcesHelper { public static void playSplashVideoResource(VideoView videoView) { int resource = R.raw.splash; String uri = "android.resource://" + videoView.getContext().getApplicationContext().getPackageName() + "/" + resource; videoView.setVideoURI(Uri.parse(uri)); } }
通过sourceSets隔离渠道资源和资源引用代码在这里就完成了,针对更加复杂的场景,就需要小伙伴根据实际情况进行扩展和修改了。下面我们来看一下如何在构建时自动将资源打包成obb文件。
-
构建时生成obb文件
要在构建时生成obb文件就必须通过添加gradle脚本来实现。我们先在项目目录下新建一个脚本文件flavour.gradle。
然后,要想打包obb文件,就必须知道现在构建的是哪个渠道的包,那要怎么拿到现在的渠道呢,请看代码:
def String getCurrentFlavor() { Gradle gradle = getGradle() String tskReqStr = gradle.getStartParameter().getTaskRequests().toString() Pattern pattern; if (tskReqStr.contains("assemble")) pattern = Pattern.compile("assemble(\\w+)(Release|Debug)") else pattern = Pattern.compile("generate(\\w+)(Release|Debug)") Matcher matcher = pattern.matcher(tskReqStr) if (matcher.find()) return matcher.group(1).toLowerCase() else { println "NO MATCH FOUND" return "" } }
我们知道obb的本质就是zip文件,所以只要在flavour.gradle中添加压缩文件的方法,就可以达到生成obb的效果了。由于笔者的Groovy语言不精通,所以这里使用java代码来解决,在flavour.gradle中添加:
import java.util.regex.Matcher import java.util.regex.Pattern import java.util.zip.ZipEntry import java.util.zip.ZipOutputStream ext { zipObb = this.&zipObb getCurrentFlavor = this.&getCurrentFlavor } //外部压缩方法入口,参数是所有需要压缩文件的目录以及输出路径,同样没有添加压缩密码逻辑,小伙伴们需要的自己添加吧 def static zipObb(File[] fs, String zipFilePath) { if (fs == null) { throw new NullPointerException("fs == null"); } ZipOutputStream zos = null; try { zos = new ZipOutputStream(new BufferedOutputStream(new FileOutputStream(zipFilePath))); for (File file : fs) { if (file == null || !file.exists()) { continue; } compress(file, zos, file.getName()); } zos.flush(); } catch (Exception e) { e.printStackTrace(); } finally { if(zos != null){ try { zos.close(); } catch (IOException e) { e.printStackTrace(); } } } } //内部递归压缩方法 def static compress(File sourceFile, ZipOutputStream zos, String name) throws Exception { byte[] buf = new byte[2048]; if (sourceFile.isFile()) { // 向zip输出流中添加一个zip实体,构造器中name为zip实体的文件的名字 zos.putNextEntry(new ZipEntry(name)); // copy文件到zip输出流中 int len; FileInputStream inputStream = new FileInputStream(sourceFile); while ((len = inputStream.read(buf)) != -1) { zos.write(buf, 0, len); } // Complete the entry zos.closeEntry(); inputStream.close(); } else { File[] listFiles = sourceFile.listFiles(); if (listFiles == null || listFiles.length == 0) { // 需要保留原来的文件结构时,需要对空文件夹进行处理 zos.putNextEntry(new ZipEntry(name + "/")); // 没有文件,不需要文件的copy zos.closeEntry(); } else { for (File file : listFiles) { compress(file, zos, name + "/" + file.getName()); } } } } def String getCurrentFlavor() { ........ }
我们已经在flavour.gradle中添加了获取当前渠道和压缩文件的方法了,现在回到app下的build.gradle文件中,通过判断当前渠道是否GooglePaly,对需要压缩的所有文件进行压缩,并输出到googlePlay渠道包apk的同级目录下:
apply from: "../flavour.gradle" //添加到文件最后 //自动打包扩展文件obb task zipObb(type: JavaExec) { //判断是否GooglePlay渠道包,获取渠道包的时候做了小写处理 if (getCurrentFlavor().equals("googleplay")) { //获取debug还是release模式输出到不同地址 String outputFilePath if(gradle.startParameter.taskNames.toString().contains("Debug")){ outputFilePath = "app/build/outputs/apk/GooglePlay/debug/main." + android.defaultConfig.versionCode + ".com.example.testobb.obb" }else{ outputFilePath = "app/GooglePlay/release/main." + android.defaultConfig.versionCode + ".com.example.testobb.obb" } File file = new File('app/src/tencent/res/raw/splash.mp4') //此处添加更多文件 也可以通过配置文件的方式输入需要打包obb的所有资源文件 File[] files = new File[]{file} zipObb(files, outputFilePath) } }
至此,我们的多渠道打包和自动化生成obb就实现了。
如发现任何错误或有不明白的地方可以留言。