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 └─_ 
修改方法:
源码解读 流程图:
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)                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" )) {                                                                          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 private  final  ByteQueue mProcessToTerminalIOQueue = new  ByteQueue(4096 );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) {                              }         }     }.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) {                              }         }     }.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" );     }          struct  termios  tios ;     tcgetattr(ptm, &tios);     tios.c_iflag |= IUTF8;     tios.c_iflag &= ~(IXON | IXOFF);     tcsetattr(ptm, TCSANOW, &tios);          struct  winsize  sz  = {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  {                  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;                          if  (asprintf(&error_message, "chdir(\"%s\")" , cwd) == -1 ) error_message = "chdir()" ;             perror(error_message);             fflush(stderr );         }         execvp(cmd, argv);                  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:
在本地使用 bundletool 命令行工具 
将 bundle上传到 Google Play Console,然后使用新的内部测试轨道 
 
在 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 
一些问题 
从非GP的渠道下载base.apk:采用Google官方的方法,即提示用户跳转GP安装最新版 
从Apk更新到aab:测试之后发现可以正常更新 
分享apk:Instant Apps/茄子快传分享Split Apk时可以组装成一个完整的apk 
Google Play签名计划会使用我们自己的签名文件去签名(而不是我们手动签名)(v1,v2) 
 
附录