-
Notifications
You must be signed in to change notification settings - Fork 268
Qigsaw 源码分析之Android App Bundle 初窥门径
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 打包插件关系类图,
- LibraryPlugin 对应 com.android.library,其编译产物格式为aar。
- InstantAppPlugin 对应 com.android.instantapp,其编译产物格式为zip。
- FeaturePlugin 对应 com.android.feature,其编译产物格式为apk。
- AppPlugin 对应 com.android.application,其编译产物格式为apk。
- 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。
前文提到 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.apk、base-xxhdpi、base-zh.apk 三个 apk 来安装。base-master.apk 是 base apk,base-xxhdpi 和 base-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-xxhdpi、base-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.apk 和 java-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 工作的基础。为后续文章讲解打下坚实基础。