Skip to content

Qigsaw 源码分析之Android App Bundle 初窥门径

kissonchen edited this page Feb 11, 2020 · 1 revision

Android App Bundles 初窥门径

Android App Bundles 是 Google 于 2018 年推出的全新应用分发方式,它核心目的是协助应用减少安装体积。依据最新 Android Dev Summit 2019 相关介绍,目前全球已有超过 25 万应用采用 App Bundles 方式分发应用并且提升了 25% 安装率。因此,国内一些专注海外市场的公司可以尝试 App Bundles。

出于某些安全因素考虑,大部分厂商还处于观望状态,毕竟使用 App Bundles 方案,您需要将 App 签名上传至 Play Console。

Android App Bundles 核心功能之一是动态分发即 dynamic features,本文将重点介绍 dynamic features 工作原理。首先请请前往 DynamicFeatures 示例地址并下载体验。

在 DynamicFeatures 项目目录的 features 文件夹下有五个 dynamic features 模块。

apply plugin: 'com.android.dynamic-feature'

android {

    compileSdkVersion versions.compileSdk

    defaultConfig {
        minSdkVersion versions.minSdk
        targetSdkVersion versions.targetSdk
        testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
    }
}

dependencies {

    implementation project(':app')

    androidTestImplementation "androidx.test.espresso:espresso-contrib:${versions.espresso}"
    androidTestImplementation "androidx.test:rules:${versions.testRules}"
    androidTestImplementation "androidx.test.ext:junit:${versions.extJunit}"

    // When using API in base, some dependencies might have to be re-added for test implementation.
    androidTestImplementation "androidx.appcompat:appcompat:${versions.appcompat}"


}

上述代码出自 java 模块 build.gradle 文件,java 模块使用的 gradle plugin 是 com.android.dynamic-feature,该插件对应 Android Gradle Plugin 源码类是 DynamicFeaturePlugin。

上图是 Android 打包插件关系类图,

  1. LibraryPlugin 对应 com.android.library,其编译产物格式为aar。
  2. InstantAppPlugin 对应 com.android.instantapp,其编译产物格式为zip。
  3. FeaturePlugin 对应 com.android.feature,其编译产物格式为apk。
  4. AppPlugin 对应 com.android.application,其编译产物格式为apk。
  5. DynamicFeaturePlugin 对应 com.android.dynamic-feature,其编译产物格式为apk。

InstantAppPlugin 和 FeaturePlugin 两个插件为 Instant Apps 开发使用。

DynamicFeaturePlugin 与 AppPlugin 均继承自 AbstractAppPlugin,因此它们之间有很多共性之处。

为了更清晰理解 dynamic feature 工作原理,我们点击 Run 'app' 按钮运行 sample 。点击 Android Studio 右下角 Event Log 按钮查看执行的任务。

Executing tasks: [
:features:java:assembleDebug, 
:features:initialInstall:assembleDebug, 
:instant:url:assembleDebug, 
:instant:split:assembleDebug, 
:features:kotlin:assembleDebug,
:app:assembleDebug, 
:features:assets:assembleDebug, 
:features:native:assembleDebug
] in project ......

从该任务中可以看出除了app模块 assemble 任务被执行外,所有 dynamic feature 模块 assemble 任务也被执行。

另外需要注意,在点击 Run 'app' 后并不是总会执行上述任务。

点击上图 Edit Configurations 按钮,进入配置页面。

Deploy 方式选择 APK from app bundle,接着再次 Run 'app',Event Log 显示的执行任务如下。

Deploy 默认是 Default APK 方式。

Executing tasks: [:app:extractApksForDebug] in project .....

依据 Deploy 方式不同,Run 'app' 后执行的任务不同。

那么 Default APK 和 APK from app bundle 两种 Deploy 方式有何不同呢?

使用 Default APK 方式启动 sample app,点击 START KOTLIN FEATURE 按钮,正常启动 dynamic-feature kotlin 的KotlinSampleActivity。

使用 APK from app bundle 方式启动 sample app,点击 START KOTLIN FEATURE 按钮,停留在 kotlin 正在启动画面,KotlinSampleActivity 未正常启动。

Android Studio 3.4+ 开始,应用安装命令不再输出。为弄明白两者区别,需下载 Android Studio 3.2+ 或 3.3+ 版本,本文以 3.3.2 为例。官方 sample 工程如果直接修改 Android Gradle Plugin 版本号至 3.3.2,会编译异常。因此,我本地写了一个 demo,只包含一个 dynamic feature 模块 java。

首先查看 Default APK 方式安装日志,点击Android Studio 左下底角 Run 按钮。

10/30 15:11:54: Launching app
$ adb install-multiple -r -t 
/Users/kissonchen/Dev/AABSample/app/build/outputs/apk/debug/app-debug.apk 
/Users/kissonchen/Dev/AABSample/features/java/build/outputs/apk/debug/java-debug.apk 
Split APKs installed in 2 s 495 ms
......

通过日志,清晰看到该方式采用 adb install-multiple 命令安装了四个 apk,除了应用自身的 app-debug.apk,还有三个 dynamic-feature 生成的三个 apk。

测试中发现 vivo 和 oppo 手机不支持多 apk 安装。

接着,再观察 APK from app bundle 方式安装日志。

10/30 15:24:14: Launching app
$ adb install-multiple -r -t 
/Users/kissonchen/Dev/AABSample/app/build/intermediates/extracted_apks/debug/extractApksForDebug/out/base-armeabi_v7a_2.apk 
/Users/kissonchen/Dev/AABSample/app/build/intermediates/extracted_apks/debug/extractApksForDebug/out/base-master_2.apk 
/Users/kissonchen/Dev/AABSample/app/build/intermediates/extracted_apks/debug/extractApksForDebug/out/base-zh.apk 
/Users/kissonchen/Dev/AABSample/app/build/intermediates/extracted_apks/debug/extractApksForDebug/out/base-xxhdpi.apk 
Split APKs installed in 1 s 912 ms
......

通过日志,可以看到该方式采取用 adb install-multiple 命令安装了四个 apk,这四个 apk 可以理解为是 app 模块编译产物 app-debug.apk 基于当前设备配置来拆分的。比如 base-armeabi_v7a_2.apk 只包含适配当前设备的 Library 文件,base-zh.apk 至包含当前设备语言环境的资源文件等。 需要注意,该种方式 java 模块相关 apk 并未安装,这又是为什么呢?

通过 APK from app bundle 方式安装应用,Android Studio 会依据 dynamic-feature 模块的 AndroidManifest.xml 中 "onDemand" 配置来决定是否安装其编译生成的 apk。这就是前文提到官方 sample 中,kotlin 模块的 KotlinSampleActivity 无法启动的原因,你可以尝试修改 kotlin 模块 "onDemand" 值为 false,再次观察结果。

从 Android 5.0 开始,Android 支持一个应用拆分成多个APK进行安装,包括 base apk 和 split apks。使用 adb install-multiple 或 PackageInstaller 可以完成多 APK 安装。

split apks 不能单独安装。base apk 安装成功后,split apks 才能安装。

为了更好理解 split apks,我们可以查看下split apks AndroidManifest.xml文件。

DefaultAPK 安装方式,查看官方 sample 中 java 模块编译产物 java-debug.apk 中 AndroidManifest.xml 文件。

<?xml version="1.0" encoding="utf-8"?>
<manifest
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:dist="http://schemas.android.com/apk/distribution"
    android:versionCode="1"
    android:versionName="1.0"
    android:isFeatureSplit="true"
    android:compileSdkVersion="28"
    android:compileSdkVersionCodename="9"
    package="com.google.android.samples.dynamicfeatures.ondemand"
    platformBuildVersionCode="28"
    platformBuildVersionName="9"
    split="java">
    
......
    
</manifest>
    

上述内容有两个属性记录该 split 的名字和类型。

android:isFeatureSplit 说明该 apk 是 dynamic-feature 的产物。 split="java" 指明该split 名称为 java。 package="com.google.android.samples.dynamicfeatures.ondemand" 内容与 app-debug 记录的包名一致。

APK form app bundle 安装方式,查看官方 sample 中 app/build/intermediates/extracted_apks/debug/extractApksForDebug/out 路径下 split apks,选择 initialInstall-xxhdpi.apk 查看其 AndroidManifest.xml 内容。

<?xml version="1.0" encoding="utf-8"?>
<manifest
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:versionCode="1"
    configForSplit="initialInstall"
    package="com.google.android.samples.dynamicfeatures.ondemand"
    split="initialInstall.config.xxhdpi">

    <application
        android:hasCode="false" />
</manifest>

configForSplit="initialInstall" 指明该 split 是 initialInstall 的配置 apk。 如果 split 是配置 apk,那么其名称会包含 config 关键字,同时其 android:hasCode 属性一直为false。

Split APKs 安装

前文提到 Run 'app',两种不同 Deploy 方式均采取 adb install-multiple 命令安装 base apk 和 split apks。 那么我们可以尝试手动调用 adb install-multiple 命令来安装应用。

我们直接使用 APK form app bundle 这种 Deploy 方式的编译产物来实践,切至 app/build/intermediates/extracted_apks/debug/extractApksForDebug/out 目录下。

选取 base-master.apkbase-xxhdpibase-zh.apk 三个 apk 来安装。base-master.apk 是 base apk,base-xxhdpibase-zh.apk 是 split apks。

安装命令如下。

adb install-multiple 
/Users/kissonchen/GitHub/app-bundle-samples/DynamicFeatures/app/build/intermediates/extracted_apks/debug/extractApksForDebug/out/base-master.apk
/Users/kissonchen/GitHub/app-bundle-samples/DynamicFeatures/app/build/intermediates/extracted_apks/debug/extractApksForDebug/out/base-xxhdpi.apk 
/Users/kissonchen/GitHub/app-bundle-samples/DynamicFeatures/app/build/intermediates/extracted_apks/debug/extractApksForDebug/out/base-zh.apk

如果仅安装 base-xxhdpibase-zh.apk 两个 apk,能成功安装吗?

执行如下命令。

adb install-multiple 
/Users/kissonchen/GitHub/app-bundle-samples/DynamicFeatures/app/build/intermediates/extracted_apks/debug/extractApksForDebug/out/base-xxhdpi.apk 
/Users/kissonchen/GitHub/app-bundle-samples/DynamicFeatures/app/build/intermediates/extracted_apks/debug/extractApksForDebug/out/base-zh.apk

运行结果如下。

adb: failed to finalize session
Failure [INSTALL_FAILED_INVALID_APK: Full install must include a base package]

通过错误日志可知,多 APK 的安装必须要保证 base apk 存在。 如果当前设备已经安装官方 sample base apk。能否通过 adb install-multiple 继续安装 split apks呢。

base*.apk 安装至设备后,继续安装 java-master.apkjava-xxhdpi.apk 两个 split apks。

保持 sample app 前台运行,执行如下命令。

adb install-multiple -p com.google.android.samples.dynamicfeatures.ondemand  
/Users/kissonchen/GitHub/app-bundle-samples/DynamicFeatures/app/build/intermediates/extracted_apks/debug/extractApksForDebug/out/java-master.apk 
/Users/kissonchen/GitHub/app-bundle-samples/DynamicFeatures/app/build/intermediates/extracted_apks/debug/extractApksForDebug/out/java-xxhdpi.apk

在上述命令执行成功后,你会发现 sample app 被系统“杀死”,重启 sample 并点击 START JAVA FEATURE 按钮,java 模块的页面被正常启动,说明 java split 被成功安装。

-p 参数表示 partial application install,意思是部分安装。后面的参数值 com.google.android.samples.dynamicfeatures.ondemand 表示 sample app 包名。

如果不指定包名参数值,会有什么现象呢?

执行以下命令。

adb install-multiple -p  
/Users/kissonchen/GitHub/app-bundle-samples/DynamicFeatures/app/build/intermediates/extracted_apks/debug/extractApksForDebug/out/java-master.apk 
/Users/kissonchen/GitHub/app-bundle-samples/DynamicFeatures/app/build/intermediates/extracted_apks/debug/extractApksForDebug/out/java-xxhdpi.apk

提示错误如下。

java.lang.IllegalArgumentException: Missing inherit package name
        at com.android.server.pm.PackageManagerShellCommand.makeInstallParams(PackageManagerShellCommand.java:2212)
        at com.android.server.pm.PackageManagerShellCommand.runInstallCreate(PackageManagerShellCommand.java:977)
        at com.android.server.pm.PackageManagerShellCommand.onCommand(PackageManagerShellCommand.java:173)
        at android.os.ShellCommand.exec(ShellCommand.java:103)
        at com.android.server.pm.PackageManagerService.onShellCommand(PackageManagerService.java:23384)
        at android.os.Binder.shellCommand(Binder.java:642)
        at android.os.Binder.onTransact(Binder.java:540)
        at android.content.pm.IPackageManager$Stub.onTransact(IPackageManager.java:2804)
        at com.android.server.pm.PackageManagerService.onTransact(PackageManagerService.java:4435)
        at com.android.server.pm.HwPackageManagerService.onTransact(HwPackageManagerService.java:994)
        at android.os.Binder.execTransact(Binder.java:739)

该异常说明未指定需继承应用的包名,即已经安装至设备 base apk 的包名。

通过堆栈信息可知,adb install-multiple 命令的实现在 PackageManagerShellCommand 类中。

private InstallParams makeInstallParams() {
2134        final SessionParams sessionParams = new SessionParams(SessionParams.MODE_FULL_INSTALL);
2135        final InstallParams params = new InstallParams();
2136        params.sessionParams = sessionParams;
2137        String opt;
2138        boolean replaceExisting = true;
2139        while ((opt = getNextOption()) != null) {
2140            switch (opt) {
2141                case "-l":
2142                    sessionParams.installFlags |= PackageManager.INSTALL_FORWARD_LOCK;
2143                    break;
2144                case "-r": // ignore
2145                    break;
2146                case "-R":
2147                    replaceExisting = false;
2148                    break;
2149                case "-i":
2150                    params.installerPackageName = getNextArg();
2151                    if (params.installerPackageName == null) {
2152                        throw new IllegalArgumentException("Missing installer package");
2153                    }
2154                    break;
2155                case "-t":
2156                    sessionParams.installFlags |= PackageManager.INSTALL_ALLOW_TEST;
2157                    break;
2158                case "-s":
2159                    sessionParams.installFlags |= PackageManager.INSTALL_EXTERNAL;
2160                    break;
2161                case "-f":
2162                    sessionParams.installFlags |= PackageManager.INSTALL_INTERNAL;
2163                    break;
2164                case "-d":
2165                    sessionParams.installFlags |= PackageManager.INSTALL_ALLOW_DOWNGRADE;
2166                    break;
2167                case "-g":
2168                    sessionParams.installFlags |= PackageManager.INSTALL_GRANT_RUNTIME_PERMISSIONS;
2169                    break;
2170                case "--dont-kill":
2171                    sessionParams.installFlags |= PackageManager.INSTALL_DONT_KILL_APP;
2172                    break;
2173                case "--originating-uri":
2174                    sessionParams.originatingUri = Uri.parse(getNextArg());
2175                    break;
2176                case "--referrer":
2177                    sessionParams.referrerUri = Uri.parse(getNextArg());
2178                    break;
2179                case "-p":
2180                    sessionParams.mode = SessionParams.MODE_INHERIT_EXISTING;
2181                    sessionParams.appPackageName = getNextArg();
2182                    if (sessionParams.appPackageName == null) {
2183                        throw new IllegalArgumentException("Missing inherit package name");
2184                    }
2185                    break;
2186                case "--pkg":
2187                    sessionParams.appPackageName = getNextArg();
2188                    if (sessionParams.appPackageName == null) {
2189                        throw new IllegalArgumentException("Missing package name");
2190                    }
2191                    break;
2192                case "-S":
2193                    final long sizeBytes = Long.parseLong(getNextArg());
2194                    if (sizeBytes <= 0) {
2195                        throw new IllegalArgumentException("Size must be positive");
2196                    }
2197                    sessionParams.setSize(sizeBytes);
2198                    break;
2199                case "--abi":
2200                    sessionParams.abiOverride = checkAbiArgument(getNextArg());
2201                    break;
2202                case "--ephemeral":
2203                case "--instant":
2204                case "--instantapp":
2205                    sessionParams.setInstallAsInstantApp(true /*isInstantApp*/);
2206                    break;
2207                case "--full":
2208                    sessionParams.setInstallAsInstantApp(false /*isInstantApp*/);
2209                    break;
2210                case "--preload":
2211                    sessionParams.setInstallAsVirtualPreload();
2212                    break;
2213                case "--user":
2214                    params.userId = UserHandle.parseUserArg(getNextArgRequired());
2215                    break;
2216                case "--install-location":
2217                    sessionParams.installLocation = Integer.parseInt(getNextArg());
2218                    break;
2219                case "--force-uuid":
2220                    sessionParams.installFlags |= PackageManager.INSTALL_FORCE_VOLUME_UUID;
2221                    sessionParams.volumeUuid = getNextArg();
2222                    if ("internal".equals(sessionParams.volumeUuid)) {
2223                        sessionParams.volumeUuid = null;
2224                    }
2225                    break;
2226                case "--force-sdk":
2227                    sessionParams.installFlags |= PackageManager.INSTALL_FORCE_SDK;
2228                    break;
2229                default:
2230                    throw new IllegalArgumentException("Unknown option " + opt);
2231            }
2232            if (replaceExisting) {
2233                sessionParams.installFlags |= PackageManager.INSTALL_REPLACE_EXISTING;
2234            }
2235        }
2236        return params;
2237    }

上述代码片段是 makeInstallParams 方法,作用是构造 apk 安装参数。代码段中 case "-p" 逻辑中抛出的异常就是导致前文安装异常的原因。

代码片段截取自 PackageManagerShellCommand

PackageManagerShellCommand 中安装 apk 逻辑最终也是通过 PackageInstaller 实现。

使用 PackageInstaller 提供的相关接口,即可通过代码实现 base apk 和 split apks 的安装。

import android.app.PendingIntent;
import android.app.Service;
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageInstaller;
import android.os.IBinder;
import android.support.annotation.Nullable;
import android.text.TextUtils;
import android.util.Log;

import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.HashMap;
import java.util.Map;

public class SplitAPKInstaller {

    private Context appContext;

    private PackageInstaller mPackageInstaller;

    public SplitAPKInstaller(Context context) {
        this.appContext = context;
        this.mPackageInstaller = context.getPackageManager().getPackageInstaller();
    }

    /**
     * 完整安装模式,必须包含 base apk。
     * 更多详情参考 {@link android.content.pm.PackageInstaller.SessionParams#MODE_FULL_INSTALL}
     *
     * @param apkPaths apk 路径列表
     */
    public void fullInstallApks(String[] apkPaths) throws IOException {
        installApk(apkPaths, null);
    }

    /**
     * 继承安装模式,用于安装 split apk,确保 base apk 已经安装至设备中。
     * 更多详情参考 {@link android.content.pm.PackageInstaller.SessionParams#MODE_INHERIT_EXISTING}
     *
     * @param apkPaths          apk 路径列表
     * @param targetPackageName 已安装 base apk 的包名
     */
    public void inheritInstallApks(String[] apkPaths, String targetPackageName) throws IOException {
        installApk(apkPaths, targetPackageName);
    }

    private void installApk(String[] apkPaths, String targetPackageName) throws IOException {

        Map<String, String> fileNameToPathMap = new HashMap<>();

        long apkTotalSize = 0;

        for (String apkPath : apkPaths) {
            File apkFile = new File(apkPath);
            if (apkFile.isFile()) {
                fileNameToPathMap.put(apkFile.getName(), apkPath);
                apkTotalSize += apkFile.length();
            }
        }

        final PackageInstaller.SessionParams sessionParams = makeInstallParams(targetPackageName, apkTotalSize);

        int sessionId = runInstallCreate(sessionParams);

        for (Map.Entry<String, String> entry : fileNameToPathMap.entrySet()) {
            runInstallWrite(sessionId, entry.getKey(), entry.getValue());
        }
        doCommitSession(sessionId);
    }

    private int runInstallCreate(PackageInstaller.SessionParams sessionParams) throws IOException {
        return mPackageInstaller.createSession(sessionParams);
    }

    private void runInstallWrite(int sessionId, String splitName, String apkPath) throws IOException {
        final File file = new File(apkPath);
        long sizeBytes = file.length();
        PackageInstaller.Session session = mPackageInstaller.openSession(sessionId);
        InputStream in = new FileInputStream(apkPath);
        OutputStream out = session.openWrite(splitName, 0, sizeBytes);
        byte[] buffer = new byte[65536];
        int c;
        while ((c = in.read(buffer)) != -1) {
            out.write(buffer, 0, c);
        }
        session.fsync(out);
        try {
            out.close();
            in.close();
            session.close();
        } catch (IOException ignored) {

        }
    }

    private void doCommitSession(int sessionId) throws IOException {
        PackageInstaller.Session session = mPackageInstaller.openSession(sessionId);
        //SplitApkInstallerService 用于接收安装结果
        Intent callbackIntent = new Intent(appContext, SplitApkInstallerService.class);
        PendingIntent pendingIntent = PendingIntent.getService(appContext, 0, callbackIntent, 0);
        session.commit(pendingIntent.getIntentSender());
        session.close();
    }

    private static PackageInstaller.SessionParams makeInstallParams(String targetPackageName, long totalSize) {
        final PackageInstaller.SessionParams sessionParams;
        if (TextUtils.isEmpty(targetPackageName)) {
            sessionParams = new PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL);
        } else {
            sessionParams = new PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_INHERIT_EXISTING);
            sessionParams.setAppPackageName(targetPackageName);
        }
        sessionParams.setSize(totalSize);
        return sessionParams;
    }

    public class SplitApkInstallerService extends Service {

        private static final String TAG = "SplitApkInstallerService";

        @Override
        public int onStartCommand(Intent intent, int flags, int startId) {
            int status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, -999);
            switch (status) {
                case PackageInstaller.STATUS_PENDING_USER_ACTION:
                    break;
                case PackageInstaller.STATUS_SUCCESS:
                    Log.d(TAG, "Installation succeed");
                    break;
                default:
                    Log.d(TAG, "Installation failed");
                    break;
            }
            stopSelf();
            return START_NOT_STICKY;
        }

        @Nullable
        @Override
        public IBinder onBind(Intent intent) {
            return null;
        }
    }

}

代码参考 splitapkinstall 并做适当整合。

上述代码完成多 APK 安装功能。调用 inheritInstallApks 方法即可为已经至设备的 base apk 继续安装 split apks。需要注意,第三方应用无法静默安装 split apks,系统会弹出安装器供用户选择。

上图是华为手机弹出的安装器界面。

此外,当 split apks 安装完成后,如果 base app 处于运行状态,那么其会被系统“杀死”。如果不希望 base app 在 split apks 安装成功后被希望杀死,可以通过android.content.pm.PackageInstaller.SessionParams 类的 setDontKillApp 方法来设置。不过该方法属于系统 API,第三方应用无法使用。

/** {@hide} */
@SystemApi
public void setDontKillApp(boolean dontKillApp) {
    if (dontKillApp) {
        installFlags |= PackageManager.INSTALL_DONT_KILL_APP;
    } else {
        installFlags &= ~PackageManager.INSTALL_DONT_KILL_APP;
    }
}

总结

本文主要围绕 Android App Bundle 开发、安装等知识点来让大家对其有初步认识。因 Google Play Service 在国内不可用,所以国内很多 Android 开发者对 Android 多 APK 安装机制并不了解。在介绍 Qigsaw 之前撰写此文的目的也是能够让大家了解 Qigsaw 工作的基础。为后续文章讲解打下坚实基础。