Android动态化框架:App Bundles

背景:

  • 华为搞事情了,推出了Dynamic ability SDK,开始支持App Bundles了
  • 根据Google的政策说明 ,2021 年下半年,新应用需要使用 Android App Bundle 才能在 Google Play 中发布。大小超过 150 MB 的新应用必须使用 Play Feature Delivery 或 Play Asset Delivery。

概念:

  1. App Bundle:是一种新的安卓编译打包方式,编译工具可以根据CPU架构类型、屏幕分辨率、语言等维度将一个传统的App打包成一个App集合。
  2. ABI:Application Binary Interface,应用二进制接口,和CPU架构类型相对应

优势:

App Bundles提供了一整套动态模块化App的机制,依托Google官方的插件支持,开发者可以直接进行模块化开发,而不再需要自己造轮子,也可以避免Android官方插件不断升级带来的兼容性问题。Google Play商店天然承载了更新APK的使命,用户可以直接在商店上发布新模块APK,来实现静默升级,由于是直接安装,因而不存在任何兼容性问题。按需获取对应特征APK,能够极大减小本地安装的包大小。

2.gif

如何应用到项目:

创建Bundle化App:

App Bundle:

  1. 创建dynamic feature module
  2. build bundle
  3. 验证bundle:bundletool命令,生成apps结构
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
获取链接设备信息:
java -jar bundletool.jar get-device-spec --output=tcl.json --adb=D:/Android/SDK/platform-tools/adb.exe
分割该设备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
转换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
  • 将一个模块转换为一个按需模块

  • 将Play Core库添加到您的项目中

  • 检查设备上是否已经安装了按需模块。

  • 要求立即或推迟安装按需模块。

  • 为您的按需模块处理下载/安装状态回调。

  • 要求延迟卸载按需模块。

Google

  • Play Feature Delivery

    Play Feature Delivery 使用 App Bundle 的高级功能,可将应用的某些功能配置为按条件分发或按需下载。针对每位用户的设备配置生成并提供经过优化的 APK,因此用户只需下载其运行您的应用所需的代码和资源。

    分发选项:安装时分发、按需分发、按条件分发和免安装分发

  • Play Asset Delivery

    Play Asset Delivery 使用资源包,资源包由资源(如纹理、着色器和声音)组成,但不包含可执行代码。通过 Dynamic Delivery,您可以按照以下三种分发模式自定义如何以及何时将各个资源包下载到设备上:安装时分发、快速跟进式分发和按需分发

下载、安装模块

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

private fun displayAssets() {
        //判断是否已经安装模块
    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")

        //安装请求,可添加多个模块
        val request = SplitInstallRequest.newBuilder()
                .addModule(moduleAssets)
                .build()
                //任务监听
        manager.startInstall(request)
               .addOnCompleteListener {toastAndLog("Module ${moduleAssets} installed") }
               .addOnSuccessListener {toastAndLog("Loading ${moduleAssets}") }
               .addOnFailureListener { toastAndLog("Error Loading ${moduleAssets}") }
    }
}

  • 下载安装需要时间,开发者需要做逻辑处理
  • 下载安装后模块将能直接运行,不需要重新启动
  • 下载模块超出一定大小(10M)后,用户将收到安装提示
  • 如果使用延迟安装,系统将在24小时内安装模块而不需要通过用户确认
/** 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.

原因是我们需要将这些活动的上下文附加到基础活动中。这可以通过在每个按需模块中启动的第一个活动中重载 attachBaseContext 方法来完成,并调用 SplitCompat.installActivity(this)将模块 "安装 "到应用上下文中。这样就可以注意将活动的资源从模块中加载到上下文中。

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

HuaWei

下载、安装模块:

implementation 'com.huawei.hms:dynamicability:1.0.14.302'
        *// 启动Dynamic Ability SDK*  
        FeatureCompat.install(base);
//创建FeatureInstallManager
FeatureInstallManagerFactory.create(this);
//安装请求,可以添加多个模块
FeatureInstallRequest request = FeatureInstallRequest.newBuilder()
    .addModule("SplitSampleFeature01")
    .build();
final FeatureTask<Integer> task = mFeatureInstallManager.installFeature(request);
//任务监听
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();
        }
    }
});
//状态监听,下载、安装进度等
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);
    }
}
//启动Dynamic Feature Module
startActivity(new Intent(this,Class.forName("com.huawei.android.demofeature.TestActivity")));

编译期技术

7.png

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

  • base/、feature1/ 和 feature2/:其中每个顶级目录都表示一个不同的应用模块。应用的基本模块始终包含在 App Bundle 的 base 目录中。不过,为每个功能模块的目录提供的名称由模块清单中的 split 属性指定。如需了解详情,请参阅[功能模块清单]。

  • asset_pack_1/ 和 asset_pack_2/:对于需要大量图形处理的大型应用或游戏,您可以将资产模块化处理为资源包。Asset Pack 因大小上限较高而成为游戏的理想之选。您可以按照三种分发模式(即,安装时分发、快速跟进式分发和按需分发)自定义如何以及何时将各个 Asset Pack 下载到设备上。所有 Asset Pack 都在 Google Play 上托管并从 Google Play 提供。如需详细了解如何将 Asset Pack 添加到您的 app bundle,请参阅 [Play Asset Delivery 概览]。

  • BUNDLE-METADATA/:此目录包含元数据文件,其中包含对工具或应用商店有用的信息。此类元数据文件可能包含 ProGuard 映射和应用的 DEX 文件的完整列表。此目录中的文件未打包到您应用的 APK 中。

  • 模块协议缓冲区 (\*.pb) 文件:这些文件提供了一些元数据,有助于向各个应用商店(如 Google Play)说明每个应用模块的内容。例如,BundleConfig.pb 提供了有关 bundle 本身的信息(如用于构建 app bundle 的构建工具版本),native.pbresources.pb 说明了每个模块中的代码和资源,这在 Google Play 针对不同的设备配置优化 APK 时非常有用。

  • manifest/:与 APK 不同,app bundle 将每个模块的 AndroidManifest.xml 文件存储在这个单独的目录中。

  • dex/:与 APK 不同,app bundle 将每个模块的 DEX 文件存储在这个单独的目录中。

  • res/、lib/ 和 assets/:这些目录与典型 APK 中的目录完全相同。当您上传 App Bundle 时,Google Play 会检查这些目录并且仅打包满足目标设备配置需求的文件,同时保留文件路径。

  • root/:此目录存储的文件之后会重新定位到包含此目录所在模块的任意 APK 的根目录。例如,app bundle 的 base/root/ 目录可能包含您的应用使用 Class.getResource() 加载的基于 Java 的资源。这些文件之后会重新定位到您应用的基本 APK 和 Google Play 生成的每个多 APK 的根目录。此目录中的路径也会保留下来。也就是说,目录(及其子目录)也会重新定位到 APK 的根目录。

    注意:如果此目录中的内容与 APK 根目录下的其他文件和目录发生冲突,则 Play 管理中心会在上传时拒绝整个 app bundle。例如,您不能包含 root/lib/ 目录,因为它会与每个 APK 已包含的 lib 目录发生冲突。

如何不通过Android studio生成App Bundle?答案是通过aapt2(模块APK中的资源包id并非传统的0x7f,而是往下递减的0x7e、0x7d、...)

Apk生成aab:

打包原理:将母包apk反编译,然后合并进去对应的渠道SDK的代码、资源、so文件等,然后再将合并后的内容回编译成最终的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结果目录:

–res
–AndroidManifest.xml
–resouces.pb

新建一个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

将目录下其他对应目录中的内容,直接拷贝到baseapk目录下对应的子目录中即可。

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

将这个目录压缩成base.zip,接下来就可以使用bundletool.jar生成aab文件了

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

注意:

  1. OS系统压缩会缺失默认文件导致报错(找不到manifest),可以改用windows压缩,或者采用第三方压缩工具
  2. 由于apktool在反编译时会把versionCode写在apktool.yml文件,manifest则会缺失,这将会导致执行命令时报错

3.验证aab

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

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

运行期技术

Google

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

安装目录可以安装多Apk的特性是Android 5.0开始引入的,这也就解释了为何4.4以下机型只能安装一个单独的完整包

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商店的服务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是通过绑定应用商店服务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;
            }
   
 ... ...

通过系统服务的AIDLcom.huawei.hms.feature.IRemoteDynamicCompat,这里尝试反编译华为应用商店,并未发现有该服务,所以猜测是华为基于AGC平台的系统服务。

开发者可以与AGC交互,利用App Bundle技术,对App中的某些模块实现动态的加载。

另外在追踪代码的时候,发现SDK是通过ContentResolver从content://com.huawei.hms获取bundle,也能佐证这一点

探索模仿这种安装方式对App自身进行更新

从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);
    }
}

相关命令

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);
    }
}

关键类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方法,才能在安装后不被强制关闭
  3. 模块APK的AndroidManifest中需要指定<manifest ... ... package="com.taobao.myappbundledemo" split="feature0">
  4. 会有安装提示
  5. 如果模块已经被安装,当他进行覆盖安装更新时,必须冷启动App才能生效。

根据官方文档,在Android 7.0及以上版本的设备是可以直接请求安装模块后立即进行使用,而对于6.0以下版本的机型,是无法直接使用下载的新模块的。不过Google也提供了一种兼容方式,使得低版本机型可以即时使用新模块,那就是采用SplitCompat

直接继承SplitCompatApplication,或者在attachBaseContext里面调用SplitCompat.install(this)。

SplitCompat的本质,就是类似冷启动的热修复的方式,也就是通过ClassLoader在运行时加载dex和资源,插入新模块包。

注意:如果两个不同的模块依赖了相同的库,需要先改为provided(gradle 3.0.0以上称为compileOnly)依赖,而在base模块中引入compile(gradle 3.0.0以上称为api)依赖。如果两个模块引入了相同的依赖,两个依赖会被分别打进两个模块的APK中。

局限:

  • 仅限于通过 Google Play 发布的应用,Goolge Play商店无法在国内正常使用。

  • 最低支持版本Android 5.0 (API level 21),低于Android 5.0 (API level 21) 的版本GooglePlay会优化Size,但不支持动态交付。

  • 新的清单条目与系统界面组件(如通知)的模块资源都无法即时使用。

  • 对于已有模块的更新,必须进行冷启动。

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

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 211,884评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,347评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,435评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,509评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,611评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,837评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,987评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,730评论 0 267
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,194评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,525评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,664评论 1 340
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,334评论 4 330
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,944评论 3 313
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,764评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,997评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,389评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,554评论 2 349

推荐阅读更多精彩内容