前言
- 一般情況下,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就實現了。
如發現任何錯誤或有不明白的地方可以留言。