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 = { .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 { 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)
附录