Google Play渠道超過100M?嘗試APK分包!

前言

  • 通常狀況下,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)。java

  • 在分包以前,開發者須要明確項目中的大容量資源文件到底是什麼,大多數狀況下,他們指的是assets目錄下的資源以及raw下的文件,若是drawablemipmap目錄下有超過1M的文件,也能夠考慮將其進行分包處理,這種狀況下須要開發者將該資源的引用方式從直接使用資源id:R.drawable.xxx改成從文件中解析。android

  • 全部的資源文件將被壓縮爲obb文件,最終上傳到GooglePlay供用戶下載。數組

obb文件

  • 概念

    什麼是obb文件,obb全稱是Opaque Binary Blob,翻譯過來是不透明的二進制對象,再進一步解析就是具備訪問權限的二進制文件。看到這個定義很容易聯想到另一種文件格式——zip壓縮包文件。因此,從本質上來講,obb文件和zip文件是同樣的,它們只是在不一樣領域上不一樣解釋罷了。而在Android分包領域,obb還有本身的一些規則。瀏覽器

  • 命名規則

    obb的命名規則以下:bash

    [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文件夾下找到。這是一個命令行工具,具體用法和參數以下:app

      $ jobb -d [全部資源的路徑] -o [生成的obb名稱(請遵循上述命名規則)] -k [打包密碼] -pn [包名] -pv [versionCode(跟obb名稱的versionCode一致)]
      複製代碼

      也可使用該工具對obb文件進行解壓:ide

      $  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 testMANAGE 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 packageGoogle 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就實現了。

    如發現任何錯誤或有不明白的地方能夠留言。

相關文章
相關標籤/搜索