Android動(dòng)態(tài)化框架:App Bundles

背景:

  • 華為搞事情了触菜,推出了Dynamic ability SDK影晓,開始支持App Bundles了
  • 根據(jù)Google的政策說明 闸天,2021 年下半年蒜魄,新應(yīng)用需要使用 Android App Bundle 才能在 Google Play 中發(fā)布扔亥。大小超過 150 MB 的新應(yīng)用必須使用 Play Feature Delivery 或 Play Asset Delivery。

概念:

  1. App Bundle:是一種新的安卓編譯打包方式谈为,編譯工具可以根據(jù)CPU架構(gòu)類型旅挤、屏幕分辨率、語言等維度將一個(gè)傳統(tǒng)的App打包成一個(gè)App集合伞鲫。
  2. ABI:Application Binary Interface粘茄,應(yīng)用二進(jìn)制接口,和CPU架構(gòu)類型相對應(yīng)

優(yōu)勢:

App Bundles提供了一整套動(dòng)態(tài)模塊化App的機(jī)制,依托Google官方的插件支持柒瓣,開發(fā)者可以直接進(jìn)行模塊化開發(fā)儒搭,而不再需要自己造輪子,也可以避免Android官方插件不斷升級(jí)帶來的兼容性問題芙贫。Google Play商店天然承載了更新APK的使命搂鲫,用戶可以直接在商店上發(fā)布新模塊APK,來實(shí)現(xiàn)靜默升級(jí)磺平,由于是直接安裝魂仍,因而不存在任何兼容性問題。按需獲取對應(yīng)特征APK拣挪,能夠極大減小本地安裝的包大小。

2.gif

如何應(yīng)用到項(xiàng)目:

創(chuàng)建Bundle化App:

App Bundle:

  1. 創(chuàng)建dynamic feature module
  2. build bundle
  3. 驗(yàn)證bundle:bundletool命令仑氛,生成apps結(jié)構(gòu)
3.png
bundleTool命令:

生成apks文件:
java -jar bundletool.jar build-apks --bundle=bundle.aab --output=bundle.apks --ks=features.jks --ks-pass=pass:tcl123 --ks-key-alias=key0 --key-pass=pass:tcl123
安裝apks:
java -jar bundletool.jar install-apks --apks=bundle.apks
獲取鏈接設(shè)備信息:
java -jar bundletool.jar get-device-spec --output=tcl.json --adb=D:/Android/SDK/platform-tools/adb.exe
分割該設(shè)備apks:
java -jar bundletool.jar extract-apks --apks=bundle.apks --output-dir=D:bundleapks --device-spec=tcl.json
安裝分割apk:
adb install-multiple .\outputs\bundle\debug\splits\base-master.apk .\outputs\bundle\debug\splits\base-xxxhdpi.apk .\outputs\bundle\debug\splits\base-zh.apk
轉(zhuǎn)換aab為完整apk:
java -jar bundletool.jar build-apks --bundle= app-debug.aab --output=aab-un.apks --mode=universal

  • Converting a module into an on-demand one
  • Adding Play Core library to your project
  • Checking if an on-demand module is already installed on the device
  • Request the immediate or deferred installation of an on-demand module
  • Handle download/installation status callback for your on-demand modules
  • Request the deferred uninstallation of an on-demand module
  • 將一個(gè)模塊轉(zhuǎn)換為一個(gè)按需模塊

  • 將Play Core庫添加到您的項(xiàng)目中

  • 檢查設(shè)備上是否已經(jīng)安裝了按需模塊锯岖。

  • 要求立即或推遲安裝按需模塊出吹。

  • 為您的按需模塊處理下載/安裝狀態(tài)回調(diào)辙喂。

  • 要求延遲卸載按需模塊捶牢。

Google

  • Play Feature Delivery

    Play Feature Delivery 使用 App Bundle 的高級(jí)功能,可將應(yīng)用的某些功能配置為按條件分發(fā)或按需下載巍耗。針對每位用戶的設(shè)備配置生成并提供經(jīng)過優(yōu)化的 APK秋麸,因此用戶只需下載其運(yùn)行您的應(yīng)用所需的代碼和資源。

    分發(fā)選項(xiàng):安裝時(shí)分發(fā)炬太、按需分發(fā)灸蟆、按條件分發(fā)和免安裝分發(fā)

  • Play Asset Delivery

    Play Asset Delivery 使用資源包,資源包由資源(如紋理亲族、著色器和聲音)組成炒考,但不包含可執(zhí)行代碼。通過 Dynamic Delivery霎迫,您可以按照以下三種分發(fā)模式自定義如何以及何時(shí)將各個(gè)資源包下載到設(shè)備上:安裝時(shí)分發(fā)斋枢、快速跟進(jìn)式分發(fā)和按需分發(fā)

下載、安裝模塊

implementation "com.google.android.play:core:${versions.playcore}"
android:name="com.google.android.play.core.splitcompat.SplitCompatApplication"
//創(chuàng)建SplitInstallManager
SplitInstallManagerFactory.create(this)

private fun displayAssets() {
        //判斷是否已經(jīng)安裝模塊
    if (manager.installedModules.contains(moduleAssets)) {
        // Get the asset manager with a refreshed context, to access content of newly installed apk.
        val assetManager = createPackageContext(packageName, 0).assets
        // Now treat it like any other asset file.
        val assets = assetManager.open("assets.txt")
        val assetContent = assets.bufferedReader()
                .use {
                    it.readText()
                }

        AlertDialog.Builder(this)
                .setTitle("Asset content")
                .setMessage(assetContent)
                .show()
    } else {
        toastAndLog("The assets module is not installed")

        //安裝請求知给,可添加多個(gè)模塊
        val request = SplitInstallRequest.newBuilder()
                .addModule(moduleAssets)
                .build()
                //任務(wù)監(jiān)聽
        manager.startInstall(request)
               .addOnCompleteListener {toastAndLog("Module ${moduleAssets} installed") }
               .addOnSuccessListener {toastAndLog("Loading ${moduleAssets}") }
               .addOnFailureListener { toastAndLog("Error Loading ${moduleAssets}") }
    }
}

  • 下載安裝需要時(shí)間瓤帚,開發(fā)者需要做邏輯處理
  • 下載安裝后模塊將能直接運(yùn)行,不需要重新啟動(dòng)
  • 下載模塊超出一定大小(10M)后缘滥,用戶將收到安裝提示
  • 如果使用延遲安裝轰胁,系統(tǒng)將在24小時(shí)內(nèi)安裝模塊而不需要通過用戶確認(rèn)
/** Listener used to handle changes in state for install requests. */
private val listener = SplitInstallStateUpdatedListener { state ->
    val multiInstall = state.moduleNames().size > 1
    val names = state.moduleNames().joinToString(" - ")
    when (state.status()) {
        SplitInstallSessionStatus.DOWNLOADING -> {
            //  In order to see this, the application has to be uploaded to the Play Store.
            displayLoadingState(state, "Downloading $names")
        }
        SplitInstallSessionStatus.REQUIRES_USER_CONFIRMATION -> {
            /*
              This may occur when attempting to download a sufficiently large module.

              In order to see this, the application has to be uploaded to the Play Store.
              Then features can be requested until the confirmation path is triggered.
             */
            startIntentSender(state.resolutionIntent()?.intentSender, null, 0, 0, 0)
        }
        SplitInstallSessionStatus.INSTALLED -> {
            onSuccessfulLoad(names, launch = !multiInstall)
        }

        SplitInstallSessionStatus.INSTALLING -> displayLoadingState(state, "Installing $names")
        SplitInstallSessionStatus.FAILED -> {
            toastAndLog("Error: ${state.errorCode()} for module ${state.moduleNames()}")
        }
    }
}

override fun onResume() {
    // Listener can be registered even without directly triggering a download.
    manager.registerListener(listener)
    super.onResume()
}

override fun onPause() {
    // Make sure to dispose of the listener once it's no longer needed.
    manager.unregisterListener(listener)
    super.onPause()
}

The reason is that we need to attach the context of these activities to the base one. This can be done by overloading the attachBaseContext method in the first activity that is launched in every on-demand module and "installing" the module into the app context calling SplitCompat.installActivity(this). This takes care to load the activity's resources from the module into the context.

原因是我們需要將這些活動(dòng)的上下文附加到基礎(chǔ)活動(dòng)中。這可以通過在每個(gè)按需模塊中啟動(dòng)的第一個(gè)活動(dòng)中重載 attachBaseContext 方法來完成朝扼,并調(diào)用 SplitCompat.installActivity(this)將模塊 "安裝 "到應(yīng)用上下文中赃阀。這樣就可以注意將活動(dòng)的資源從模塊中加載到上下文中。

   
override fun attachBaseContext(newBase: Context?) {
        super.attachBaseContext(newBase)
        SplitCompat.installActivity(this)
    }

HuaWei

下載榛斯、安裝模塊:

implementation 'com.huawei.hms:dynamicability:1.0.14.302'
        *// 啟動(dòng)Dynamic Ability SDK*  
        FeatureCompat.install(base);
//創(chuàng)建FeatureInstallManager
FeatureInstallManagerFactory.create(this);
//安裝請求,可以添加多個(gè)模塊
FeatureInstallRequest request = FeatureInstallRequest.newBuilder()
    .addModule("SplitSampleFeature01")
    .build();
final FeatureTask<Integer> task = mFeatureInstallManager.installFeature(request);
//任務(wù)監(jiān)聽
task.addOnListener(new OnFeatureSuccessListener<Integer>() {
    @Override
    public void onSuccess(Integer integer) {
        Log.d(TAG, "load feature onSuccess.session id:" + integer);
    }
});
task.addOnListener(new OnFeatureFailureListener<Integer>() {
    @Override
    public void onFailure(Exception exception) {
        if (exception instanceof FeatureInstallException) {
            int errorCode = ((FeatureInstallException) exception).getErrorCode();
            Log.d(TAG, "load feature onFailure.errorCode:" + errorCode);
        } else {
            exception.printStackTrace();
        }
    }
});
//狀態(tài)監(jiān)聽王凑,下載索烹、安裝進(jìn)度等
private InstallStateListener mStateUpdateListener = new InstallStateListener() {
    @Override
    public void onStateUpdate(InstallState state) {
        Log.d(TAG, "install session state " + state);
        if (state.status() == FeatureInstallSessionStatus.REQUIRES_USER_CONFIRMATION) {
            try {
                mFeatureInstallManager.triggerUserConfirm(state, SampleEntry.this, 1);
            } catch (IntentSender.SendIntentException e) {
                e.printStackTrace();
            }
            return;
        }

        if (state.status() == FeatureInstallSessionStatus.REQUIRES_PERSON_AGREEMENT) {
            try {
                mFeatureInstallManager.triggerUserConfirm(state, SampleEntry.this, 1);
            } catch (IntentSender.SendIntentException e) {
                e.printStackTrace();
            }
            return;
        }
    
        if (state.status() == FeatureInstallSessionStatus.INSTALLED) {
            Log.i(TAG, "installed success ,can use new feature");
            makeToast("installed success , can test new feature ");
            return;
        }
    
        if (state.status() == FeatureInstallSessionStatus.UNKNOWN) {
            Log.e(TAG, "installed in unknown status");
            makeToast("installed in unknown status ");
            return;
        }
    
        if (state.status() == FeatureInstallSessionStatus.DOWNLOADING) {
            long process = state.bytesDownloaded() * 100 / state.totalBytesToDownload();
            Log.d(TAG, "downloading  percentage: " + process);
            makeToast("downloading  percentage: " + process);
            return;
        }
    
        if (state.status() == FeatureInstallSessionStatus.FAILED) {
            Log.e(TAG, "installed failed, errorcode : " + state.errorCode());
            makeToast("installed failed, errorcode : " + state.errorCode());
            return;
        }
    
    }

};

@Override
protected void onResume() {
    super.onResume();
    if (mFeatureInstallManager != null) {
        mFeatureInstallManager.registerInstallListener(installStateListener);
    }
}

@Override
protected void onPause() {
    super.onPause();
    if (mFeatureInstallManager != null) {
        mFeatureInstallManager.unregisterInstallListener(installStateListener);
    }
}
//啟動(dòng)Dynamic Feature Module
startActivity(new Intent(this,Class.forName("com.huawei.android.demofeature.TestActivity")));

編譯期技術(shù)

7.png

如何生成aab文件:
Android Studio->Build->Build Bundles

  • base/垒拢、feature1/ 和 feature2/:其中每個(gè)頂級(jí)目錄都表示一個(gè)不同的應(yīng)用模塊求类。應(yīng)用的基本模塊始終包含在 App Bundle 的 base 目錄中。不過张症,為每個(gè)功能模塊的目錄提供的名稱由模塊清單中的 split 屬性指定。如需了解詳情兆衅,請參閱[功能模塊清單]羡亩。

  • asset_pack_1/ 和 asset_pack_2/:對于需要大量圖形處理的大型應(yīng)用或游戲雷袋,您可以將資產(chǎn)模塊化處理為資源包楷怒。Asset Pack 因大小上限較高而成為游戲的理想之選鸠删。您可以按照三種分發(fā)模式(即刃泡,安裝時(shí)分發(fā)、快速跟進(jìn)式分發(fā)和按需分發(fā))自定義如何以及何時(shí)將各個(gè) Asset Pack 下載到設(shè)備上庙楚。所有 Asset Pack 都在 Google Play 上托管并從 Google Play 提供馒闷。如需詳細(xì)了解如何將 Asset Pack 添加到您的 app bundle纳账,請參閱 [Play Asset Delivery 概覽]。

  • BUNDLE-METADATA/:此目錄包含元數(shù)據(jù)文件卧秘,其中包含對工具或應(yīng)用商店有用的信息翅敌。此類元數(shù)據(jù)文件可能包含 ProGuard 映射和應(yīng)用的 DEX 文件的完整列表蚯涮。此目錄中的文件未打包到您應(yīng)用的 APK 中张峰。

  • 模塊協(xié)議緩沖區(qū) (\*.pb) 文件:這些文件提供了一些元數(shù)據(jù)喘批,有助于向各個(gè)應(yīng)用商店(如 Google Play)說明每個(gè)應(yīng)用模塊的內(nèi)容谤祖。例如,BundleConfig.pb 提供了有關(guān) bundle 本身的信息(如用于構(gòu)建 app bundle 的構(gòu)建工具版本)额湘,native.pbresources.pb 說明了每個(gè)模塊中的代碼和資源锋华,這在 Google Play 針對不同的設(shè)備配置優(yōu)化 APK 時(shí)非常有用毯焕。

  • manifest/:與 APK 不同纳猫,app bundle 將每個(gè)模塊的 AndroidManifest.xml 文件存儲(chǔ)在這個(gè)單獨(dú)的目錄中。

  • dex/:與 APK 不同块差,app bundle 將每個(gè)模塊的 DEX 文件存儲(chǔ)在這個(gè)單獨(dú)的目錄中状蜗。

  • res/轧坎、lib/ 和 assets/:這些目錄與典型 APK 中的目錄完全相同眶根。當(dāng)您上傳 App Bundle 時(shí),Google Play 會(huì)檢查這些目錄并且僅打包滿足目標(biāo)設(shè)備配置需求的文件族扰,同時(shí)保留文件路徑渔呵。

  • root/:此目錄存儲(chǔ)的文件之后會(huì)重新定位到包含此目錄所在模塊的任意 APK 的根目錄扩氢。例如录豺,app bundle 的 base/root/ 目錄可能包含您的應(yīng)用使用 Class.getResource() 加載的基于 Java 的資源。這些文件之后會(huì)重新定位到您應(yīng)用的基本 APK 和 Google Play 生成的每個(gè)多 APK 的根目錄咏花。此目錄中的路徑也會(huì)保留下來昏翰。也就是說,目錄(及其子目錄)也會(huì)重新定位到 APK 的根目錄窍株。

    注意:如果此目錄中的內(nèi)容與 APK 根目錄下的其他文件和目錄發(fā)生沖突球订,則 Play 管理中心會(huì)在上傳時(shí)拒絕整個(gè) app bundle冒滩。例如开睡,您不能包含 root/lib/ 目錄扶檐,因?yàn)樗鼤?huì)與每個(gè) APK 已包含的 lib 目錄發(fā)生沖突款筑。

如何不通過Android studio生成App Bundle奈梳?答案是通過aapt2(模塊APK中的資源包id并非傳統(tǒng)的0x7f攘须,而是往下遞減的0x7e、0x7d限煞、...)

Apk生成aab:

打包原理:將母包apk反編譯署驻,然后合并進(jìn)去對應(yīng)的渠道SDK的代碼、資源宣吱、so文件等,然后再將合并后的內(nèi)容回編譯成最終的apk疤坝。

1跑揉、編譯資源

aapt2 compile --dir ./res -o ./compiled_resources
aapt2 link --proto-format -o baseapk -I ../android.jar --manifest ./AndroidManifest.xml -R ./compiled_resources/*.flat --auto-add-overlay

baseapk結(jié)果目錄:

–res
–AndroidManifest.xml
–resouces.pb

新建一個(gè)manifest目錄,然后我們將AndroidManifest.xml拷貝到manifest子目錄下具练。

–res
–manifest
——AndroidManfiest.xml
–resources.pb

2、編譯dex

java -jar smali.jar assemble -o ./baseapk/dex/classes.dex ./smali

–res
–manifest
——AndroidManfiest.xml
–dex
——classes.dex
——classes2.dex
–resources.pb

將目錄下其他對應(yīng)目錄中的內(nèi)容岂丘,直接拷貝到baseapk目錄下對應(yīng)的子目錄中即可。

–assets
–lib
–root
–res
–manifest
——AndroidManfiest.xml
–dex
——classes.dex
——classes2.dex
–resources.pb

將這個(gè)目錄壓縮成base.zip寨蹋,接下來就可以使用bundletool.jar生成aab文件了

java -jar bundletool.jar build-bundle --modules= ./base.zip --output=./output.aab

注意:

  1. OS系統(tǒng)壓縮會(huì)缺失默認(rèn)文件導(dǎo)致報(bào)錯(cuò)(找不到manifest)已旧,可以改用windows壓縮运褪,或者采用第三方壓縮工具
  2. 由于apktool在反編譯時(shí)會(huì)把versionCode寫在apktool.yml文件,manifest則會(huì)缺失璃诀,這將會(huì)導(dǎo)致執(zhí)行命令時(shí)報(bào)錯(cuò)

3.驗(yàn)證aab

java -jar bundletool.jar build-apks --bundle=./output.aab --output=./output.apks

java -jar bundletool.jar install-apks --apks=./output.apks

運(yùn)行期技術(shù)

Google

–base.apk
–lib
–oat
–split_feature0.apk

安裝目錄可以安裝多Apk的特性是Android 5.0開始引入的蔑匣,這也就解釋了為何4.4以下機(jī)型只能安裝一個(gè)單獨(dú)的完整包劣欢。

splitInstallManager.startInstall(request)
final class k {
    private static final b b = new b("SplitInstallService");
    private static final Intent c = new Intent("com.google.android.play.core.splitinstall.BIND_SPLIT_INSTALL_SERVICE").setPackage("com.android.vending");
    final com.google.android.play.core.a.b<a> a;
    private final Context d;
    private final String e;
    private final f f;
    
    private k(Context context, String str) {
        this.f = new j(this);
        this.d = context;
        this.e = str;
        this.a = new com.google.android.play.core.a.b(context.getApplicationContext(), b, "SplitInstallService", c, l.a, this.f);
    }

    public final Task<Integer> a(Collection<String> collection) {
        b.a("startInstall(%s)", collection);
        i iVar = new i();
        this.a.a(new m(this, iVar, collection, iVar));
        return iVar.a();
    }

... ...
public final class b<T extends IInterface> {
    private static final Map<String, Handler> a = Collections.synchronizedMap(new HashMap());
    private final Context b;
    private final com.google.android.play.core.splitcompat.b.b c;
    private final String d;
    private final List<a> e = new ArrayList();
    private boolean f;
    private final Intent g;
    private final g<T> h;
    private final WeakReference<f> i;
    private final DeathRecipient j = new c(this);
    private ServiceConnection k;
    private T l;

    public b(Context context, com.google.android.play.core.splitcompat.b.b bVar, String str, Intent intent, g<T> gVar, f fVar) {
        this.b = context;
        this.c = bVar;
        this.d = str;
        this.g = intent;
        this.h = gVar;
        this.i = new WeakReference(fVar);
    }

    private final void b(a aVar) {
        if (this.l == null && !this.f) {
            this.c.a("Initiate binding to the service.", new Object[0]);
            this.e.add(aVar);
            this.k = new h();
            this.f = true;
            if (!this.b.bindService(this.g, this.k, 1)) {
                this.c.a("Failed to bind to the service.", new Object[0]);
                this.f = false;
                for (a a : this.e) {
                    i a2 = a.a();
                    if (a2 != null) {
                        a2.a(new k());
                    }
                }
                this.e.clear();
            }
        } else if (this.f) {
            this.c.a("Waiting to bind to the service.", new Object[0]);
            this.e.add(aVar);
        } else {
            aVar.run();
        }
    }


... ...

綁定Play商店的服務(wù)com.google.android.play.core.splitinstall.BIND_SPLIT_INSTALL_SERVICE

商店的filter:

<service android:name="com.google.android.finsky.splitinstallservice.SplitInstallService" android:enabled="true" android:exported="true" android:visibleToInstantApps="true">
    <meta-data android:name="instantapps.clients.allowed" android:value="true"/>
    <intent-filter>
        <action android:name="com.google.android.play.core.splitinstall.BIND_SPLIT_INSTALL_SERVICE"/>
    </intent-filter>
</service>

可以看出SplitInstallManager是通過綁定應(yīng)用商店服務(wù)SplitInstallService請求下載安裝。

Huawei

public interface c extends IInterface {
    void install(com.huawei.hms.feature.b var1) throws RemoteException;

    public abstract static class b extends Binder implements c {
        private static final String a = "com.huawei.hms.feature.IRemoteDynamicCompat";
        static final int b = 1;

        public b() {
            this.attachInterface(this, "com.huawei.hms.feature.IRemoteDynamicCompat");
        }

        public static c asInterface(IBinder var0) {
            if (var0 == null) {
                return null;
            } else {
                IInterface var1;
                return (c)((var1 = var0.queryLocalInterface("com.huawei.hms.feature.IRemoteDynamicCompat")) != null && var1 instanceof c ? (c)var1 : new c.b.a(var0));
            }
        }

        public boolean onTransact(int var1, Parcel var2, Parcel var3, int var4) throws RemoteException {
            String var5 = "com.huawei.hms.feature.IRemoteDynamicCompat";
            if (var1 != 1) {
                if (var1 != 1598968902) {
                    return super.onTransact(var1, var2, var3, var4);
                } else {
                    var3.writeString(var5);
                    return true;
                }
            } else {
                var2.enforceInterface(var5);
                this.install(com.huawei.hms.feature.b.b.a(var2.readStrongBinder()));
                var3.writeNoException();
                return true;
            }
        }
      
 ... ...
   
   private static class a implements c {
    public static c a;
    private IBinder b;

    a(IBinder var1) {
        this.b = var1;
    }

    public IBinder asBinder() {
        return this.b;
    }

    public String a() {
        return "com.huawei.hms.feature.IRemoteDynamicCompat";
    }

    public void install(com.huawei.hms.feature.b var1) throws RemoteException {
        Parcel var2;
        Parcel var10001 = var2 = Parcel.obtain();
        Parcel var3 = Parcel.obtain();
        String var10002 = "com.huawei.hms.feature.IRemoteDynamicCompat";

        Throwable var10000;
        label464: {
            boolean var62;
            try {
                var10001.writeInterfaceToken(var10002);
            } catch (Throwable var60) {
                var10000 = var60;
                var62 = false;
                break label464;
            }
   
 ... ...

通過系統(tǒng)服務(wù)的AIDLcom.huawei.hms.feature.IRemoteDynamicCompat裁良,這里嘗試反編譯華為應(yīng)用商店氧秘,并未發(fā)現(xiàn)有該服務(wù),所以猜測是華為基于AGC平臺(tái)的系統(tǒng)服務(wù)趴久。

開發(fā)者可以與AGC交互,利用App Bundle技術(shù),對App中的某些模塊實(shí)現(xiàn)動(dòng)態(tài)的加載。

另外在追蹤代碼的時(shí)候谎脯,發(fā)現(xiàn)SDK是通過ContentResolver從content://com.huawei.hms獲取bundle废麻,也能佐證這一點(diǎn)

探索模仿這種安裝方式對App自身進(jìn)行更新

從bundletool入手

private String createMultiInstallSession(List<File> apkFiles, String pmOptions, long timeout, TimeUnit unit) throws TimeoutException, AdbCommandRejectedException, ShellCommandUnresponsiveException, IOException {
    long totalFileSize = 0;
    for (File apkFile : apkFiles) {
        totalFileSize += apkFile.length();
    }
    InstallCreateReceiver receiver = new InstallCreateReceiver();
    this.mDevice.executeShellCommand(String.format(this.mPrefix + " install-create %1$s -S %2$d", new Object[]{pmOptions, Long.valueOf(totalFileSize)}), receiver, timeout, unit);
    return receiver.getSessionId();
}


private boolean uploadApk(String sessionId, File fileToUpload, int uniqueId, long timeout, TimeUnit unit) {
    Throwable e;
    Throwable th;
    Log.d(sessionId, String.format("Uploading APK %1$s ", new Object[]{fileToUpload.getPath()}));
    if (!fileToUpload.exists()) {
        Log.e(sessionId, String.format("File not found: %1$s", new Object[]{fileToUpload.getPath()}));
        return false;
    } else if (fileToUpload.isDirectory()) {
        Log.e(sessionId, String.format("Directory upload not supported: %1$s", new Object[]{fileToUpload.getAbsolutePath()}));
        return false;
    } else {
        String baseName;
        if (fileToUpload.getName().lastIndexOf(46) != -1) {
            baseName = fileToUpload.getName().substring(0, fileToUpload.getName().lastIndexOf(46));
        } else {
            baseName = fileToUpload.getName();
        }
        baseName = UNSAFE_PM_INSTALL_SESSION_SPLIT_NAME_CHARS.replaceFrom(baseName, '_');
        Log.d(sessionId, String.format("Executing : %1$s", new Object[]{String.format(this.mPrefix + " install-write -S %d %s %d_%s -", new Object[]{Long.valueOf(fileToUpload.length()), sessionId, Integer.valueOf(uniqueId), baseName})}));
... ...        
    }
}


public void install(long timeout, TimeUnit unit) throws InstallException {
    try {
        String sessionId = createMultiInstallSession(this.mApks, this.mOptions, timeout, unit);
        if (sessionId == null) {
            Log.d(LOG_TAG, "Failed to establish session, quit installation");
            throw new InstallException("Failed to establish session");
        }
        int index = 0;
        boolean allUploadSucceeded = true;
        while (allUploadSucceeded) {
            if (index >= this.mApks.size()) {
                break;
            }
            int index2 = index + 1;
            allUploadSucceeded = uploadApk(sessionId, (File) this.mApks.get(index), index, timeout, unit);
            index = index2;
        }
        String command = this.mPrefix + " install-" + (allUploadSucceeded ? "commit " : "abandon ") + sessionId;
        InstallReceiver receiver = new InstallReceiver();
        this.mDevice.executeShellCommand(command, receiver, timeout, unit);
        if (receiver.getErrorMessage() != null) {
            String message = String.format("Failed to finalize session : %1$s", new Object[]{receiver.getErrorMessage()});
            Log.e(LOG_TAG, message);
            throw new InstallException(message);
        } else if (!allUploadSucceeded) {
            throw new InstallException("Failed to install all ");
        }
    } catch (InstallException e) {
        throw e;
    } catch (Throwable e2) {
        throw new InstallException(e2);
    }
}

相關(guān)命令

adb shell pm install-create ...
adb shell pm install-write ...
adb shell pm install-commit ...
frameworks-base-p-preview-1/cmds/pm/src/com/android/commands/pm/Pm.java
public int run(String[] args) throws RemoteException {
... ...
    mPm = IPackageManager.Stub.asInterface(ServiceManager.getService("package"));

    mInstaller = mPm.getPackageInstaller();
... ...
    
    if ("install-create".equals(op)) {
        return runInstallCreate();
    }

    if ("install-write".equals(op)) {
        return runInstallWrite();
    }

    if ("install-commit".equals(op)) {
        return runInstallCommit();
    }
... ...
}

private int runInstallCreate() throws RemoteException {
    final InstallParams installParams = makeInstallParams();
    final int sessionId = doCreateSession(installParams.sessionParams,
            installParams.installerPackageName, installParams.userId);

    // NOTE: adb depends on parsing this string
    System.out.println("Success: created install session [" + sessionId + "]");
    return PackageInstaller.STATUS_SUCCESS;
}

private int doCreateSession(SessionParams params, String installerPackageName, int userId)
        throws RemoteException {
    userId = translateUserId(userId, "runInstallCreate");
    if (userId == UserHandle.USER_ALL) {
        userId = UserHandle.USER_SYSTEM;
        params.installFlags |= PackageManager.INSTALL_ALL_USERS;
    }

    final int sessionId = mInstaller.createSession(params, installerPackageName, userId);
    return sessionId;
}
private int runInstallWrite() throws RemoteException {
    long sizeBytes = -1;

    String opt;
    while ((opt = nextOption()) != null) {
        if (opt.equals("-S")) {
            sizeBytes = Long.parseLong(nextArg());
        } else {
            throw new IllegalArgumentException("Unknown option: " + opt);
        }
    }

    final int sessionId = Integer.parseInt(nextArg());
    final String splitName = nextArg();
    final String path = nextArg();
    return doWriteSession(sessionId, path, sizeBytes, splitName, true /*logSuccess*/);
}

private int doWriteSession(int sessionId, String inPath, long sizeBytes, String splitName,
        boolean logSuccess) throws RemoteException {
    if (STDIN_PATH.equals(inPath)) {
        inPath = null;
    } else if (inPath != null) {
        final File file = new File(inPath);
        if (file.isFile()) {
            sizeBytes = file.length();
        }
    }

    final SessionInfo info = mInstaller.getSessionInfo(sessionId);

    PackageInstaller.Session session = null;
    InputStream in = null;
    OutputStream out = null;
    try {
        session = new PackageInstaller.Session(
                mInstaller.openSession(sessionId));

        if (inPath != null) {
            in = new FileInputStream(inPath);
        } else {
            in = new SizedInputStream(System.in, sizeBytes);
        }
        out = session.openWrite(splitName, 0, sizeBytes);

        int total = 0;
        byte[] buffer = new byte[65536];
        int c;
        while ((c = in.read(buffer)) != -1) {
            total += c;
            out.write(buffer, 0, c);

            if (info.sizeBytes > 0) {
                final float fraction = ((float) c / (float) info.sizeBytes);
                session.addProgress(fraction);
            }
        }
        session.fsync(out);

        if (logSuccess) {
            System.out.println("Success: streamed " + total + " bytes");
        }
        return PackageInstaller.STATUS_SUCCESS;
    } catch (IOException e) {
        System.err.println("Error: failed to write; " + e.getMessage());
        return PackageInstaller.STATUS_FAILURE;
    } finally {
        IoUtils.closeQuietly(out);
        IoUtils.closeQuietly(in);
        IoUtils.closeQuietly(session);
    }
}

關(guān)鍵類PackageInstaller绍刮,可以用它做什么?

PackageInstaller.SessionParams sessionParams = new PackageInstaller.SessionParams(
        PackageInstaller.SessionParams.MODE_INHERIT_EXISTING);
sessionParams.getClass().getDeclaredMethod("setDontKillApp", boolean.class).invoke(sessionParams, true);

PackageInstaller installer = context.getPackageManager().getPackageInstaller();
int sessionId = installer.createSession(sessionParams);
PackageInstaller.Session session = installer.openSession(sessionId);

File apkFile = new File(getFilesDir(), "feature0-debug.apk");
in = new FileInputStream(apkFile.getPath());
out = session.openWrite("anything", 0, apkFile.length());
int total = 0;
byte[] buffer = new byte[65536];
int c;
while ((c = in.read(buffer)) != -1) {
    total += c;
    out.write(buffer, 0, c);
}
session.fsync(out);

IntentSender intentSender = createIntentSender(context, sessionId);
session.commit(intentSender);

session.close();
  1. SessionParams需要指定模式為MODE_INHERIT_EXISTING,才能覆蓋安裝模塊APK
  2. 需要通過反射SessionParams的setDontKillApp方法诽偷,才能在安裝后不被強(qiáng)制關(guān)閉
  3. 模塊APK的AndroidManifest中需要指定<manifest ... ... package="com.taobao.myappbundledemo" split="feature0">
  4. 會(huì)有安裝提示
  5. 如果模塊已經(jīng)被安裝飞苇,當(dāng)他進(jìn)行覆蓋安裝更新時(shí)忿等,必須冷啟動(dòng)App才能生效。

根據(jù)官方文檔,在Android 7.0及以上版本的設(shè)備是可以直接請求安裝模塊后立即進(jìn)行使用,而對于6.0以下版本的機(jī)型苗胀,是無法直接使用下載的新模塊的澜驮。不過Google也提供了一種兼容方式,使得低版本機(jī)型可以即時(shí)使用新模塊,那就是采用SplitCompat屁倔。

直接繼承SplitCompatApplication,或者在attachBaseContext里面調(diào)用SplitCompat.install(this)。

SplitCompat的本質(zhì)本讥,就是類似冷啟動(dòng)的熱修復(fù)的方式序无,也就是通過ClassLoader在運(yùn)行時(shí)加載dex和資源拔莱,插入新模塊包。

注意:如果兩個(gè)不同的模塊依賴了相同的庫篷店,需要先改為provided(gradle 3.0.0以上稱為compileOnly)依賴姓蜂,而在base模塊中引入compile(gradle 3.0.0以上稱為api)依賴。如果兩個(gè)模塊引入了相同的依賴览绿,兩個(gè)依賴會(huì)被分別打進(jìn)兩個(gè)模塊的APK中逛绵。

局限:

  • 僅限于通過 Google Play 發(fā)布的應(yīng)用硕蛹,Goolge Play商店無法在國內(nèi)正常使用。

  • 最低支持版本Android 5.0 (API level 21),低于Android 5.0 (API level 21) 的版本GooglePlay會(huì)優(yōu)化Size该互,但不支持動(dòng)態(tài)交付胰丁。

  • 新的清單條目與系統(tǒng)界面組件(如通知)的模塊資源都無法即時(shí)使用随橘。

  • 對于已有模塊的更新,必須進(jìn)行冷啟動(dòng)锦庸。

  • 需要加入到 Google 的 In app signing by Google Play in the Play Console

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末机蔗,一起剝皮案震驚了整個(gè)濱河市,隨后出現(xiàn)的幾起案子甘萧,更是在濱河造成了極大的恐慌萝嘁,老刑警劉巖,帶你破解...
    沈念sama閱讀 221,548評(píng)論 6 515
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件幔嗦,死亡現(xiàn)場離奇詭異酿愧,居然都是意外死亡沥潭,警方通過查閱死者的電腦和手機(jī)邀泉,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 94,497評(píng)論 3 399
  • 文/潘曉璐 我一進(jìn)店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來钝鸽,“玉大人汇恤,你說我怎么就攤上這事“吻。” “怎么了因谎?”我有些...
    開封第一講書人閱讀 167,990評(píng)論 0 360
  • 文/不壞的土叔 我叫張陵,是天一觀的道長颜懊。 經(jīng)常有香客問我财岔,道長风皿,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 59,618評(píng)論 1 296
  • 正文 為了忘掉前任匠璧,我火速辦了婚禮桐款,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘夷恍。我一直安慰自己魔眨,他們只是感情好,可當(dāng)我...
    茶點(diǎn)故事閱讀 68,618評(píng)論 6 397
  • 文/花漫 我一把揭開白布酿雪。 她就那樣靜靜地躺著遏暴,像睡著了一般。 火紅的嫁衣襯著肌膚如雪指黎。 梳的紋絲不亂的頭發(fā)上朋凉,一...
    開封第一講書人閱讀 52,246評(píng)論 1 308
  • 那天,我揣著相機(jī)與錄音醋安,去河邊找鬼侥啤。 笑死,一個(gè)胖子當(dāng)著我的面吹牛茬故,可吹牛的內(nèi)容都是我干的盖灸。 我是一名探鬼主播,決...
    沈念sama閱讀 40,819評(píng)論 3 421
  • 文/蒼蘭香墨 我猛地睜開眼磺芭,長吁一口氣:“原來是場噩夢啊……” “哼赁炎!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起钾腺,我...
    開封第一講書人閱讀 39,725評(píng)論 0 276
  • 序言:老撾萬榮一對情侶失蹤徙垫,失蹤者是張志新(化名)和其女友劉穎,沒想到半個(gè)月后放棒,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體姻报,經(jīng)...
    沈念sama閱讀 46,268評(píng)論 1 320
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 38,356評(píng)論 3 340
  • 正文 我和宋清朗相戀三年间螟,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了吴旋。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點(diǎn)故事閱讀 40,488評(píng)論 1 352
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡厢破,死狀恐怖荣瑟,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情摩泪,我是刑警寧澤笆焰,帶...
    沈念sama閱讀 36,181評(píng)論 5 350
  • 正文 年R本政府宣布,位于F島的核電站见坑,受9級(jí)特大地震影響嚷掠,放射性物質(zhì)發(fā)生泄漏捏检。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,862評(píng)論 3 333
  • 文/蒙蒙 一不皆、第九天 我趴在偏房一處隱蔽的房頂上張望未檩。 院中可真熱鬧,春花似錦粟焊、人聲如沸冤狡。這莊子的主人今日做“春日...
    開封第一講書人閱讀 32,331評(píng)論 0 24
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽悲雳。三九已至,卻和暖如春香追,著一層夾襖步出監(jiān)牢的瞬間合瓢,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 33,445評(píng)論 1 272
  • 我被黑心中介騙來泰國打工透典, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留晴楔,地道東北人。 一個(gè)月前我還...
    沈念sama閱讀 48,897評(píng)論 3 376
  • 正文 我出身青樓峭咒,卻偏偏與公主長得像税弃,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個(gè)殘疾皇子凑队,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 45,500評(píng)論 2 359

推薦閱讀更多精彩內(nèi)容