0%

Termux-AAB分享

Termux

概述

Termux是一个Android下一个高级的终端模拟器,开源且不需要root,支持apt管理软件包,拥有自己的apt仓库源,可以十分方便地安装软件包,可以实现支持Python,PHP,Ruby,Go,Nodejs,MySQL等功能环境。

仓库地址

一切皆文件

linux/unix下的哲学核心思想是一切皆文件。它指的是,对所有文件(目录、字符设备、块设备、套接字、打印机、进程、线程、管道等)操作,读写都可用fopen()/fclose()/fwrite()/fread()等函数进行处理,屏蔽了硬件的区别,所有设备都抽象成文件,提供统一的接口给用户,虽然类型各不相同,但是对其提供的却是同一套操作界面,更进一步,对文件的操作也可以跨文件系统执行。

操作一个已经打开的文件:使用文件描述符(file descriptor),简称fd,它是一个对应某个已经打开的文件的索引(非负整数)。

文件描述符

文件描述符在形式上是一个非负整数。实际上,它是一个索引值,指向内核为每一个进程所维护的该进程打开文件的记录表。当程序打开一个现有文件或者创建一个新文件时,内核向进程返回一个文件描述符。在程序设计中,一些涉及底层的程序编写往往会围绕着文件描述符展开。

Linux 系统中,把一切都看做是文件,当进程打开现有文件或创建新文件时,内核向进程返回一个文件描述符,文件描述符就是内核为了高效管理已被打开的文件所创建的索引,用来指向被打开的文件,所有执行I/O操作的系统调用都会通过文件描述符。

Termux工作方式

apt&dpkg

在Linux平台下使用源代码进行软件编译可以具有定制化的设置,但对于Linux distribution的发行商来说,毕竟不是每个人都会进行源代码编译的,这个问题将会严重的影响linux平台上软件的发行与推广。

为了解决上述的问题,厂商先在他们的系统上面编译好了用户所需要的软件,然后将这个编译好并可执行的软件直接发布给用户安装。不同的 Linux 发行版使用不同的打包系统,一般而言,大多数发行版分别属于两大包管理技术阵营:Debian 的”.deb”,和 Red Hat的”.rpm”。

dpkg是Debian的一个底层包管理工具,主要用于对已下载到本地和已安装的软件包进行管理。

虽然我们在使用dpkg时,已经解决掉了软件安装过程中的大量问题,但是当依赖关系不满足时,仍然需要手动解决,而apt这个工具解决了这样的问题,linux distribution 先将软件放置到对应的服务器中,然后分析软件的依赖关系,并且记录下来,然后当客户端有安装软件需求时,通过清单列表与本地的dpkg以存在的软件数据相比较,就能从网络端获取所有需要的具有依赖属性的软件了。

Termux在它自己维护的apt源中有它编译好的deb软件包,这些deb针对的是 applicationId为 com.termux 的应用。

Termux提供了什么

  • 在Android Linux内核的基础上,提供了一些更为丰富的命令以及它们的依赖,比如说 bash(Android系统自带的shell是位于/system/bin下的sh,sh支持的功能比较有限),apt(可以说是对Android sh最为强大的扩展,通过apt可以安装编译好的各种软件,包括Python)。
  • 通过在app的进程中fork子进程,通过文件操作开启一个终端,然后通过返回的文件描述符对终端进行操作,首先执行login(termux)或者bash(termux)或者sh(/system/bin/sh)命令,得到一个可执行shell操作的环境。后续才可继续进行安装python等操作。
  • 为了支持这些扩展的命令,需要配置一些环境变量(包括Path等)。

修改包名

为什么需要修改依赖的包名?

  • termux-app 的 默认applicationId是 com.termux,如果需要修改applicationId的话,则需要重新编译所有的deb包以及提供一个apt源,并且重新生成bootstraps.zip

Python官网的deb解包后目录结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
└─data
├─usr
│ ├─bin
│ ├─lib
│ │ └─valgrind
│ └─share
│ ├─apps
│ │ └─konsole
│ ├─doc
│ │ └─python
│ ├─lintian
│ │ └─overrides
│ ├─man
│ │ └─man1
│ └─pixmaps

termux 编译后的目录结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
├─data
│ └─data
│ └─$pkg
│ └─files
│ └─usr
│ ├─bin
│ ├─include
│ │ └─python2.7
│ ├─lib
│ │ ├─pkgconfig
│ │ └─python2.7
│ └─share
│ ├─doc
│ │ └─python2
│ └─man
│ └─man1
└─_

修改方法:

源码解读

流程图:

Termux流程图

setupIfNeeded

bootstraps.zip目录结构:

1
2
3
4
5
6
7
8
9
10
11
bootstrap-aarch64
├── SYMLINKS.txt
├── bin
├── etc
├── include
├── lib
├── libexec
├── repo.asc
├── share
├── tmp
└── var
1
2
3
public static final String FILES_PATH = "/data/data/$pkg/files";
public static final String PREFIX_PATH = FILES_PATH + "/usr";
public static final String HOME_PATH = FILES_PATH + "/home";
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
public static void setupIfNeeded(final Activity activity, final Runnable whenDone) {
// Termux can only be run as the primary user (device owner) since only that
// account has the expected file system paths. Verify that:
UserManager um = (UserManager) activity.getSystemService(Context.USER_SERVICE);
boolean isPrimaryUser = um.getSerialNumberForUser(android.os.Process.myUserHandle()) == 0;
if (!isPrimaryUser) {
Termux.mInstance.setInstalled(false);
Termux.mInstance.getTermuxHandle().initFail();
return;
}

final File PREFIX_FILE = new File(Termux.PREFIX_PATH);
if (PREFIX_FILE.isDirectory()) {
whenDone.run();
return;
}

new Thread() {
@Override
public void run() {
try {
final String STAGING_PREFIX_PATH = Termux.FILES_PATH + "/usr-staging";
final File STAGING_PREFIX_FILE = new File(STAGING_PREFIX_PATH);

if (STAGING_PREFIX_FILE.exists()) {
deleteFolder(STAGING_PREFIX_FILE);
}

final byte[] buffer = new byte[8096];
final List<Pair<String, String>> symlinks = new ArrayList<>(50);

final URL zipUrl = determineZipUrl();
try (ZipInputStream zipInput = new ZipInputStream(zipUrl.openStream())) {
ZipEntry zipEntry;
while ((zipEntry = zipInput.getNextEntry()) != null) {
if (zipEntry.getName().equals("SYMLINKS.txt")) {
BufferedReader symlinksReader = new BufferedReader(new InputStreamReader(zipInput));
String line;
while ((line = symlinksReader.readLine()) != null) {
String[] parts = line.split("←");
if (parts.length != 2)
throw new RuntimeException("Malformed symlink line: " + line);
String oldPath = parts[0];
String newPath = STAGING_PREFIX_PATH + "/" + parts[1];
symlinks.add(Pair.create(oldPath, newPath));

ensureDirectoryExists(new File(newPath).getParentFile());
}
} else {
String zipEntryName = zipEntry.getName();
File targetFile = new File(STAGING_PREFIX_PATH, zipEntryName);
boolean isDirectory = zipEntry.isDirectory();

ensureDirectoryExists(isDirectory ? targetFile : targetFile.getParentFile());

if (!isDirectory) {
try (FileOutputStream outStream = new FileOutputStream(targetFile)) {
int readBytes;
while ((readBytes = zipInput.read(buffer)) != -1)
outStream.write(buffer, 0, readBytes);
}
if (zipEntryName.startsWith("bin/") || zipEntryName.startsWith("libexec") || zipEntryName.startsWith("lib/apt/methods")) {
//noinspection OctalInteger
Os.chmod(targetFile.getAbsolutePath(), 0700);
}
}
}
}
}

if (symlinks.isEmpty())
throw new RuntimeException("No SYMLINKS.txt encountered");
for (Pair<String, String> symlink : symlinks) {
Os.symlink(symlink.first, symlink.second);
}

if (!STAGING_PREFIX_FILE.renameTo(PREFIX_FILE)) {
throw new RuntimeException("Unable to rename staging folder");
}
activity.runOnUiThread(whenDone);
} catch (final Exception e) {
Log.e(EmulatorDebug.LOG_TAG, "Bootstrap error", e);
Termux.mInstance.setInstalled(false);
Termux.mInstance.getTermuxHandle().initFail();
}
}
}.start();
}

createSession

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
private TerminalSession createSession() {
new File(HOME_PATH).mkdirs();

String[] env = BackgroundJob.buildEnvironment(false, HOME_PATH);
String executablePath = null;
for (String shellBinary : new String[]{"login", "bash", "zsh"}) {
File shellFile = new File(PREFIX_PATH + "/bin/" + shellBinary);
if (shellFile.canExecute()) {
executablePath = shellFile.getAbsolutePath();
break;
}
}

if (executablePath == null) {
XLLog.d(TermuxDebug.TAG, "fall back to system shell");
executablePath = "/system/bin/sh";
}

String[] processArgs = BackgroundJob.setupProcessArgs(executablePath, null);
executablePath = processArgs[0];
int lastSlashIndex = executablePath.lastIndexOf('/');
String processName = "-" +
(lastSlashIndex == -1 ? executablePath : executablePath.substring(lastSlashIndex + 1));

String[] args = new String[processArgs.length];
args[0] = processName;
if (processArgs.length > 1)
System.arraycopy(processArgs, 1, args, 1, processArgs.length - 1);

TerminalSession session = new TerminalSession(executablePath, HOME_PATH, args, env);
session.initializeEmulator(Integer.MAX_VALUE, 120);

return session;
}

参数如下:

1
2
3
4
5
6
mShellPath: /data/data/$pkg/files/usr/bin/login
mCwd: /data/data/$pkg/files/home
mArgs: [-login]
mEnv: [TERM=xterm-256color, HOME=/data/data/$pkg/files/home, PREFIX=/data/data/$pkg/files/usr, BOOTCLASSPATH/system/framework/com.qualcomm.qti.camera.jar:/system/framework/QPerformance.jar:/system/framework/core-oj.jar:/system/framework/core-libart.jar:/system/framework/conscrypt.jar:/system/framework/okhttp.jar:/system/framework/bouncycastle.jar:/system/framework/apache-xml.jar:/system/framework/legacy-test.jar:/system/framework/ext.jar:/system/framework/framework.jar:/system/framework/telephony-common.jar:/system/framework/voip-common.jar:/system/framework/ims-common.jar:/system/framework/org.apache.http.legacy.boot.jar:/system/framework/android.hidl.base-V1.0-java.jar:/system/framework/android.hidl.manager-V1.0-java.jar:/system/framework/tcmiface.jar:/system/framework/WfdCommon.jar:/system/framework/oem-services.jar:/system/framework/qcom.fmradio.jar:/system/framework/telephony-ext.jar:/system/app/miui/miui.apk:/system/app/miuisystem/miuisystem.apk, ANDROID_ROOT=/system, ANDROID_DATA=/data, EXTERNAL_STORAGE=/sdcard, LD_LIBRARY_PATH=/data/data/$pkg/files/usr/lib, LANG=en_US.UTF-8, PATH=/data/data/$pkg/files/usr/bin:/data/data/$pkg/files/usr/bin/applets, PWD=/data/data/$pkg/files/home, TMPDIR=/data/data/$pkg/files/usr/tmp]
mShellPid: 15381
mTerminalFileDescriptor: 54

操作terminal

1
2
3
4
5
6
7
8
9
10
/**
* A queue written to from a separate thread when the process outputs, and read by main thread to process by
* terminal emulator.
*/
private final ByteQueue mProcessToTerminalIOQueue = new ByteQueue(4096);
/**
* A queue written to from the main thread due to user interaction, and read by another thread which forwards by
* writing to the {@link #mTerminalFileDescriptor}.
*/
private final ByteQueue mTerminalToProcessIOQueue = new ByteQueue(4096);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
public void initializeEmulator(int columns, int rows) {

int[] processId = new int[1];
mTerminalFileDescriptor = JNI.createSubprocess(mShellPath, mCwd, mArgs, mEnv, processId, rows, columns);
mShellPid = processId[0];

final FileDescriptor terminalFileDescriptorWrapped = wrapFileDescriptor(mTerminalFileDescriptor);

new Thread("TermSessionInputReader[pid=" + mShellPid + "]") {
@Override
public void run() {
try (InputStream termIn = new FileInputStream(terminalFileDescriptorWrapped)) {
final byte[] buffer = new byte[4096];
while (true) {
int read = termIn.read(buffer);
if (read == -1) return;
if (!mProcessToTerminalIOQueue.write(buffer, 0, read)) return;
mMainThreadHandler.sendEmptyMessage(MSG_NEW_INPUT);
}
} catch (Exception e) {
// Ignore, just shutting down.
}
}
}.start();

new Thread("TermSessionOutputWriter[pid=" + mShellPid + "]") {
@Override
public void run() {
final byte[] buffer = new byte[4096];
try (FileOutputStream termOut = new FileOutputStream(terminalFileDescriptorWrapped)) {
while (true) {
int bytesToWrite = mTerminalToProcessIOQueue.read(buffer, true);
if (bytesToWrite == -1) return;
termOut.write(buffer, 0, bytesToWrite);
}
} catch (IOException e) {
// Ignore.
}
}
}.start();

new Thread("TermSessionWaiter[pid=" + mShellPid + "]") {
@Override
public void run() {
int processExitCode = JNI.waitFor(mShellPid);
mMainThreadHandler.sendMessage(mMainThreadHandler.obtainMessage(MSG_PROCESS_EXITED, processExitCode));
}
}.start();

}

createSubprocess

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
static int create_subprocess(JNIEnv* env,
char const* cmd,
char const* cwd,
char* const argv[],
char** envp,
int* pProcessId,
jint rows,
jint columns)
{
int ptm = open("/dev/ptmx", O_RDWR | O_CLOEXEC);
if (ptm < 0) return throw_runtime_exception(env, "Cannot open /dev/ptmx");

#ifdef LACKS_PTSNAME_R
char* devname;
#else
char devname[64];
#endif
if (grantpt(ptm) || unlockpt(ptm) ||
#ifdef LACKS_PTSNAME_R
(devname = ptsname(ptm)) == NULL
#else
ptsname_r(ptm, devname, sizeof(devname))
#endif
) {
return throw_runtime_exception(env, "Cannot grantpt()/unlockpt()/ptsname_r() on /dev/ptmx");
}

// Enable UTF-8 mode and disable flow control to prevent Ctrl+S from locking up the display.
struct termios tios;
tcgetattr(ptm, &tios);
tios.c_iflag |= IUTF8;
tios.c_iflag &= ~(IXON | IXOFF);
tcsetattr(ptm, TCSANOW, &tios);

/** Set initial winsize. */
struct winsize sz = { .ws_row = (unsigned short) rows, .ws_col = (unsigned short) columns };
ioctl(ptm, TIOCSWINSZ, &sz);

pid_t pid = fork();
if (pid < 0) {
return throw_runtime_exception(env, "Fork failed");
} else if (pid > 0) {
*pProcessId = (int) pid;
return ptm;
} else {
// Clear signals which the Android java process may have blocked:
sigset_t signals_to_unblock;
sigfillset(&signals_to_unblock);
sigprocmask(SIG_UNBLOCK, &signals_to_unblock, 0);

close(ptm);
setsid();

int pts = open(devname, O_RDWR);
if (pts < 0) exit(-1);

dup2(pts, 0);
dup2(pts, 1);
dup2(pts, 2);

DIR* self_dir = opendir("/proc/self/fd");
if (self_dir != NULL) {
int self_dir_fd = dirfd(self_dir);
struct dirent* entry;
while ((entry = readdir(self_dir)) != NULL) {
int fd = atoi(entry->d_name);
if(fd > 2 && fd != self_dir_fd) close(fd);
}
closedir(self_dir);
}

clearenv();
if (envp) for (; *envp; ++envp) putenv(*envp);

if (chdir(cwd) != 0) {
char* error_message;
// No need to free asprintf()-allocated memory since doing execvp() or exit() below.
if (asprintf(&error_message, "chdir(\"%s\")", cwd) == -1) error_message = "chdir()";
perror(error_message);
fflush(stderr);
}
execvp(cmd, argv);
// Show terminal output about failing exec() call:
char* error_message;
if (asprintf(&error_message, "exec(\"%s\")", cmd) == -1) error_message = "exec()";
perror(error_message);
_exit(1);
}
}

一些问题和思考

  • 最开始的版本使用的bootstrap.zip中打包进了包括bash以及apt在内的许多命令及依赖,大小有16M左右,它提供了一个比较完整的shell功能,后续可以通过apt去下载python然后获取youtube-dl,一个流程下载需要26M左右的流量
  • 由于deb包以及bootstrap.zip都是我们自己编译后放在自己的服务器上的,因此在安装python时可以按照dpkg本地安装的方式,python.deb的获取通过http的方式从服务器下载,在编译bootstrap.zip时只添加dpkg以及其依赖,其余的apt和bash都不提供,使用Android系统内部的shell客户端————sh,虽然sh功能有限,使用不方便,但是可以满足基本的一些需求。按照这种方式,打包的bootstrap.zip只包括dpkg,大小为3M,然后加上下载的python以及youtube-dl,总共13M左右(而且免去了apt update等所需的流量)。
  • 由于dpkg按照python.deb的方式需要手动解决依赖包的问题,需要手动下载许多的deb依赖包,因此最后直接在打包bootstrap.zip的时候,去掉dpkg,直接加上python的依赖,将其打包进bootstrap.zip,在客户端下载配置完bootstrap.zip后,即可直接使用python的pip下载youtube-dl,操作上方便了许多,并且避免了不同Android系统可能存在的兼容性问题。

Android App Bundle

概述

Android App Bundle是Google推出的Apk动态打包,动态组件化的技术,与Instant App不同,AAB是借助Split Apk完成动态加载,使用AAB动态下发方式,可以大幅度减少应用体积。只须在 Android Studio 中构建一个应用 (app bundle),就可以将应用所需的全部内容 (适用于所有设备) 都涵盖在内:所有语言、所有设备屏幕大小、所有硬件架构。它本身并不支持动态化,只是动态化的一个载体文件,真正实现逻辑并不是它。

Split APK

Split APK是Google为解决65536上限,以及APK安装包越来越大等问题,在Android L中引入的机制。它可以将一个庞大的APK,按屏幕密度,ABI等形式拆分成多个独立的APK,在应用程序更新时,不必下载整个APK,只需单独下载某个模块即可安装更新。Split APK将原来一个APK中多个模块共享同一份资源的模型分离成多个APK使用各自的资源,并且可以继承Base APK中的资源,多个APK有相同的data,cache目录,多个dex文件,相同的进程,在Settings.apk中只显示一个APK,并且使用相同的包名。

Building App Bundles

在gradle中配置Split的维度:

1
2
3
4
5
6
7
8
9
10
11
12
13
android {
bundle {
language {
enableSplit = true
}
density {
enableSplit = true
}
abi {
enableSplit = true
}
}
}

然后通过Android Studio/Gradle/Bundletool生成.aab文件。

Test Android App Bundle

可以通过两种方法测试aab:

  1. 在本地使用 bundletool 命令行工具
  2. 将 bundle上传到 Google Play Console,然后使用新的内部测试轨道

bundletool

Github 仓库 中下载 bundletool 工具。

从 app bundle 生成一组 APK:

1
java -jar bundletool-all-0.3.3.jar build-apks --bundle=[aab file] --output=[output file name].apks

解压.apks

解压 .apks 文件:unzip app.apks -d tmp,输出如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
Archive:  app.apks
extracting: tmp/base-hdpi.apk
extracting: tmp/base-mdpi.apk
extracting: tmp/base-ldpi.apk
extracting: tmp/base-xhdpi.apk
extracting: tmp/base-xxxhdpi.apk
extracting: tmp/base-xxhdpi.apk
extracting: tmp/base-tvdpi.apk
extracting: tmp/base-ca.apk
extracting: tmp/base-da.apk
extracting: tmp/base-fa.apk
extracting: tmp/base-ja.apk
extracting: tmp/base-ka.apk
extracting: tmp/base-pa.apk
extracting: tmp/base-ta.apk
extracting: tmp/base-nb.apk
extracting: tmp/base-be.apk
extracting: tmp/base-de.apk
extracting: tmp/base-ne.apk
extracting: tmp/base-te.apk
extracting: tmp/base-af.apk
extracting: tmp/base-bg.apk
extracting: tmp/base-th.apk
extracting: tmp/base-fi.apk
extracting: tmp/base-si.apk
extracting: tmp/base-hi.apk
extracting: tmp/base-vi.apk
extracting: tmp/base-kk.apk
extracting: tmp/base-mk.apk
extracting: tmp/base-sk.apk
extracting: tmp/base-uk.apk
extracting: tmp/base-el.apk
extracting: tmp/base-master.apk
extracting: tmp/base-gl.apk
extracting: tmp/base-nl.apk
extracting: tmp/base-ml.apk
extracting: tmp/base-pl.apk
extracting: tmp/base-sl.apk
extracting: tmp/base-tl.apk
extracting: tmp/base-am.apk
extracting: tmp/base-km.apk
extracting: tmp/base-bn.apk
extracting: tmp/base-in.apk
extracting: tmp/base-kn.apk
extracting: tmp/base-mn.apk
extracting: tmp/base-ko.apk
extracting: tmp/base-lo.apk
extracting: tmp/base-ro.apk
extracting: tmp/base-sq.apk
extracting: tmp/base-ar.apk
extracting: tmp/base-fr.apk
extracting: tmp/base-hr.apk
extracting: tmp/base-mr.apk
extracting: tmp/base-or.apk
extracting: tmp/base-sr.apk
extracting: tmp/base-tr.apk
extracting: tmp/base-ur.apk
extracting: tmp/base-as.apk
extracting: tmp/base-bs.apk
extracting: tmp/base-cs.apk
extracting: tmp/base-es.apk
extracting: tmp/base-is.apk
extracting: tmp/base-ms.apk
extracting: tmp/base-et.apk
extracting: tmp/base-it.apk
extracting: tmp/base-lt.apk
extracting: tmp/base-pt.apk
extracting: tmp/base-gu.apk
extracting: tmp/base-eu.apk
extracting: tmp/base-hu.apk
extracting: tmp/base-ru.apk
extracting: tmp/base-lv.apk
extracting: tmp/base-zu.apk
extracting: tmp/base-sv.apk
extracting: tmp/base-iw.apk
extracting: tmp/base-sw.apk
extracting: tmp/base-hy.apk
extracting: tmp/base-ky.apk
extracting: tmp/base-my.apk
extracting: tmp/base-az.apk
extracting: tmp/base-uz.apk
extracting: tmp/base-en.apk
extracting: tmp/base-zh.apk
extracting: tmp/base-armeabi_v7a.apk
extracting: tmp/base-arm64_v8a.apk
extracting: tmp/standalone-arm64_v8a_mdpi.apk
extracting: tmp/standalone-arm64_v8a_ldpi.apk
extracting: tmp/standalone-arm64_v8a_hdpi.apk
extracting: tmp/standalone-arm64_v8a_xhdpi.apk
extracting: tmp/standalone-armeabi_v7a_ldpi.apk
extracting: tmp/standalone-arm64_v8a_xxxhdpi.apk
extracting: tmp/standalone-arm64_v8a_xxhdpi.apk
extracting: tmp/standalone-arm64_v8a_tvdpi.apk
extracting: tmp/standalone-armeabi_v7a_mdpi.apk
extracting: tmp/standalone-armeabi_v7a_xhdpi.apk
extracting: tmp/standalone-armeabi_v7a_xxhdpi.apk
extracting: tmp/standalone-armeabi_v7a_hdpi.apk
extracting: tmp/standalone-armeabi_v7a_xxxhdpi.apk
extracting: tmp/standalone-armeabi_v7a_tvdpi.apk
inflating: tmp/toc.pb

可以使用如下命令安装多个 split apks:

1
adb install-multiple -r 1.apk 2.apk

Google Play Console

使用内部测试方式。

Dynamic Delivery

  • 基本 APK
  • 配置 APK
  • 动态功能 APK

一些问题

  • 从非GP的渠道下载base.apk:采用Google官方的方法,即提示用户跳转GP安装最新版
  • 从Apk更新到aab:测试之后发现可以正常更新
  • 分享apk:Instant Apps/茄子快传分享Split Apk时可以组装成一个完整的apk
  • Google Play签名计划会使用我们自己的签名文件去签名(而不是我们手动签名)(v1,v2)

附录