https://juejin.im/post/589736ad570c350062426974
http://w4lle.com/2016/12/16/tinker/
http://w4lle.com/2016/05/02/从Instant%20run谈Android替换Application和动态加载机制/
Tinker的已知问题
1、Tinker不支持修改AndroidManifest.xml,Tinker不支持新增四大组件(1.9.0支持新增非export的Activity);
2、由于Google Play的开发者条款限制,不建议在GP渠道动态更新代码;
3、在Android N上,补丁对应用启动时间有轻微的影响;
4、不支持部分三星android-21机型,加载补丁时会主动抛出"TinkerRuntimeException:checkDexInstall failed";
5、对于资源替换,不支持修改remoteView。例如transition动画,notification icon以及桌面图标。
针对第一项需要有一些说明:
Android四大基本组件分别是Activity,Service服务,Content Provider内容提供者,BroadcastReceiver广播接收器。
Activity,例如UpdateEngineActivity
BroadcastReceiver,例如NetworkStateReceiver
Service,例如AppUpdateService 强更的代码
Content Provider,例如SMSContentObserver,Provider使一个应用程序的指定数据集提供给其他应用程序。这些数据可以存储在文件系统中、在一个SQLite数据库、或以任何其他合理的方式,其他应用可以通过ContentResolver类从该内容提供者中获取或存入数据,(相当于在应用外包了一层壳),只有需要在多个应用程序间共享数据是才需要内容提供者。
Activity,service,BroadcastReceiver分为动态注册和静态注册,如果是动态注册,是生效的,静态注册是不生效的。因为tinker会检查AndroidManifest 中增加了,就会查出来,差异包就做不出来了。而修改AndroidManifest,不增加组件,只会通知AndroidManifest修改不生效。
tinker将old.apk和new.apk做了diff,拿到patch.dex,然后将patch.dex与本机中apk的classes.dex做了合并,生成新的classes.dex,运行时通过反射将合并后的dex文件放置在加载的dexElements数组的前面。
行时替代的原理,其实和Qzone的方案差不多,都是去反射修改dexElements。
两者的差异是:Qzone是直接将patch.dex插到数组的前面;而tinker是将patch.dex与app中的classes.dex合并后的全量dex插在数组的前面。
tinker这么做的目的还是因为Qzone方案中提到的CLASS_ISPREVERIFIED的解决方案存在问题;而tinker相当于换个思路解决了该问题。
接下来我们就从代码中去验证该原理。
一、ant 到 gradle
1、gradle 编程:
这种“编程”不要搞得和程序员理解的编程那样复杂。寥寥几笔,轻轻松松把要做的事情描述出来就最好不过。所以,Gradle选择了Groovy。Groovy基于Java并拓展了Java。Java程序员可以无缝切换到使用Groovy开发程序。Groovy说白了就是把写Java程序变得像写脚本一样简单。写完就可以执行,Groovy内部会将其编译成Java class然后启动虚拟机来执行。当然,这些底层的渣活不需要你管。
除了可以用很灵活的语言来写构建规则外,Gradle另外一个特点就是它是一种DSL,即Domain Specific Language,领域相关语言。
2、Groovy是一种动态语言。这种语言比较有特点,它和Java一样,也运行于Java虚拟机中。你可以认为Groovy扩展了Java语言。当执行Groovy脚本时,Groovy会先将其编译成Java类字节码,然后通过Jvm来执行这个Java类。实际上,由于Groovy Code在真正执行的时候已经变成了Java字节码,所以JVM根本不知道自己运行的是Groovy代码。
二、DEX
1、Dex文件是什么?
Dex文件是运行在Dalvik中的字节码文件,类似于运行于JVM中的class文件。
(1)什么是Dalvik?
Dalvik虚拟机是Google等厂商合作开发的Android移动设备平台的核心组成部分之一。
它可以支持已转换为 .dex格式的Java应用程序的运行,.dex格式是专为Dalvik设计的一种压缩格式,适合内存和处理器速度有限的系统。Dalvik 经过优化,允许在有限的内存中同时运行多个虚拟机的实例,并且每一个Dalvik 应用作为一个独立的Linux进程执行。独立的进程可以防止在虚拟机崩溃的时候所有程序都被关闭。
Dalvik是基于寄存器的,而JVM是基于栈的。(一般来说,基于堆栈的机器必须使用指令才能从堆栈上的加载和操作数据,因此,相对基于寄存器的机器,它们需要更多的指令才能实现相同的性能。但是基于寄存器机器上的指令必须经过编码,因此,它们的指令往往更大。)
Dalvik运行dex文件,而JVM运行java字节码。
优化后的Dalvik较其他标准虚拟机存在一些不同特性: 占用更少空间;为简化翻译,常量池只使用32位索引;标准Java字节码实行8位堆栈指令,Dalvik使用16位指令集直接作用于局部变量。局部变量通常来自4位的“虚拟寄存器”区。这样减少了Dalvik的指令计数,提高了翻译速度。
当Android启动时,Dalvik VM 监视所有的程序(APK),并且创建依存关系树,为每个程序优化代码并存储在Dalvik缓存中。Dalvik第一次加载后会生成Cache文件,以提供下次快速加载,所以第一次会很慢。
Dalvik解释器采用预先算好的Goto地址,每个指令对内存的访问都在64字节边界上对齐。这样可以节省一个指令后进行查表的时间。为了强化功能, Dalvik还提供了快速翻译器(Fast Interpreter)。
(2)什么是Dex?
其中有一个classes.dex文件。classes.dex是apk的核心文件,其运行在安卓Dalvik虚拟机上。通过查看apk的编译生成过程,我们可以得知:Java源代码首先被编译成.class文件,然后Android SDK自带的dx工具会将这些.class文件转换成classes.dex。
2、class 文件与dex文件区别 (dvm与jvm区别)
区别一:dvm执行的是.dex格式文件 jvm执行的是.class文件 Android程序编译完之后生产.class文件,然后,dex工具会把.class文件处理成.dex文件,然后把资源文件和.dex文件等打包成.apk文件。apk就是android package的意思。 jvm执行的是.class文件。
区别二:dvm是基于寄存器的虚拟机 而jvm执行是基于虚拟栈的虚拟机。寄存器存取速度比栈快的多,dvm可以根据硬件实现最大的优化,比较适合移动设备。
区别三:.class文件存在很多的冗余信息,dex工具会去除冗余信息,并把所有的.class文件整合到.dex文件中。减少了I/O操作,提高了类的查找速度
三、Android 中的Dalvik和ART是什么,有啥区别?
什么是Dalvik?
Dalvik虚拟机是Google等厂商合作开发的Android移动设备平台的核心组成部分之一。它可以支持已转换为** .dex格式**的Java应用程序的运行,.dex格式是专为Dalvik设计的一种压缩格式,适合内存和处理器速度有限的系统。Dalvik虚拟机一直被用户指责为拖慢安卓系统运行速度不如IOS的根源。
什么是ART?
即Android Runtime
ART 的机制与 Dalvik 不同。在Dalvik下,应用每次运行的时候,字节码都需要通过即时编译器(just in time ,JIT)转换为机器码,这会拖慢应用的运行效率,而在ART环境中,应用在第一次安装的时候,字节码就会预先编译成机器码,使其成为真正的本地应用。这个过程叫做预编译(AOT,Ahead-Of-Time)。这样的话,应用的启动(首次)和执行都会变得更加快速。
Android L上默认用ART替代了Dalvik。在Android 2.2时为Dalvik引入了JIT编译器,通过在应用运行时不断地剖析应用字节码和动态地把那些频繁执行的短的段落字节码编译成native机器码,那些未翻译成native的字节码便靠Dalvik虚拟机翻译执行。跟Dalvik不同的是,ART引入提前编译(AOT),也就是在应用安装时便把整个应用编译成native机器码。去掉了Dalvik的翻译执行和JIT,ART提升了整个执行效率并减少了电量消耗。
ART带来的更快的应用执行效率,优化了内存分配和垃圾回收机制,加入了新的应用调试feature(applications debugging features)和更精确的高等级的应用剖析(more accurate high-level profiling of applications)。
为了向后进行兼容,ART仍把.dex文件作为apk文件的一部分,但是.odex文件被ELF( Executable and Linkable Format )文件替代了。一旦应用程序被ART设备上的dex2oat工具编译后,运行时便是基于前面编译好的ELF可执行文件。
ART让人不爽的地方有两个,一个是ART在应用安装时需要额外的时间进行编译,另一个便是需要消耗大量的Flash存储空间来保存编译出来的字节码。
ART有什么优缺点呢?
优点:
1、系统性能的显著提升。
2、应用启动更快、运行更快、体验更流畅、触感反馈更及时。
3、更长的电池续航能力。
4、支持更低的硬件。
缺点:
1.机器码占用的存储空间更大,字节码变为机器码之后,可能会增加10%-20%(不过在应用包中,可执行的代码常常只是一部分。比如最新的 Google+ APK 是 28.3 MB,但是代码只有 6.9 MB。)
2.应用的安装时间会变长。
tips:现在智能手机大部分都可以让用户选择使用Dalvik还是ART模式。当然默认还是使用Dalvik模式。
用法:设置-辅助功能-开发者选项(开发人员工具)-选择运行环境(不同的手机设置的步骤可能不一样)。
a.如果用Dalvik,Android系统在安装应用时对.dex进行修改和优化,结果保存在 /data/dalvik-cache目录,这样加载应用时无需每次都进行优化,
b.JIT(just int ime)是运行时环境的一部分,它把解释型语言的可执行文件程序集转换成native机器码执行,ART仍把.dex文件作为标准输入文件,但是ART会把dex文件再编译成native字节码,仍把编译后的结果保存在 /data/dalvik-cache目录。
四、dex文件的差分与合成
DexDiff的目的在于对比新旧dex生成补丁数据,然后利用补丁数据和旧dex合成新dex,目前DexDiff的实现中,一般思路是这样的:
1.基于dex2jar库反编译新旧dex
2.逐个对比新旧dex中的class:
(1)若class仅存在于旧dex,保存旧dex中的class至deleteClasses
(2)若class仅存在于新dex,保存新dex中的class至replaceClasses
(3)若class存在于新旧dex,但class数据不一致,保存新dex中的class至replaceClasses
3.编译得到的replaceClasses得到replace.dex
4.记录deleteClasses中类的标识字符串得到delete.data
5.根据replace.dex、delete.data以及旧dex便可以使用dex2jar编译得到新dex
其中对比新旧dex中两个对应的class以确定其是否一致时,采用的方式是逐个对比class中的属性(accessFlags,superClass,interfaces,fields,methods等),若有一项属性不一致则定义该class为需要用新dex中数据替换旧dex中对应数据。通过这样的方式可以实现class粒度上的dex差分,这样程度的差分可以应用于生成QZone方案中的热修复补丁包了,如果做增量更新的话,class粒度下的增量包大小也是可以接受的。
五、Tinker工作机制
Tinker是在加载完整个流程之后才去调用的app中的Application的attachBaseContext开始真正的整个App的生命周期。说白了就是采用了代理。(1)补丁包在包内的加载过程(下载等过程另一篇来讲)
在补丁加载之前,我们需要知道补丁文件现在已经下发到app中,并且通过dexDiff合成并且校验然后push到/data/data/package_name/tinker/下。刚才讲到loadTinker()方法是实现Tinker加载补丁的关键,
private void loadTinker() {
//disable tinker, not need to install
if (tinkerFlags == TINKER_DISABLE) {
return;
}
tinkerResultIntent = new Intent();
try {
//reflect tinker loader, because loaderClass may be define by user!
Class<?> tinkerLoadClass = Class.forName(loaderClassName, false, getClassLoader());
Method loadMethod = tinkerLoadClass.getMethod(TINKER_LOADER_METHOD, TinkerApplication.class);
Constructor<?> constructor = tinkerLoadClass.getConstructor();
tinkerResultIntent = (Intent) loadMethod.invoke(constructor.newInstance(), this);
} catch (Throwable e) {
//has exception, put exception error code
ShareIntentUtil.setIntentReturnCode(tinkerResultIntent, ShareConstants.ERROR_LOAD_PATCH_UNKNOWN_EXCEPTION);
tinkerResultIntent.putExtra(INTENT_PATCH_EXCEPTION, e);
}
}
其中的loaderClassName是我们传过来的"com.tencent.tinker.loader.TinkerLoader",反射调用TinkerLoader的tryLoad()方法拿到加载补丁结果,这里为什么也要用反射,是因为Tinker做了很多扩展性的工作,TinkerLoader只是默认实现,开发者完全可以自己定义加载器完成加载流程。
校验分为很多步骤会判断补丁是否存在,检查补丁信息中的数据是否有效,校验补丁签名以及tinkerId与基准包是否一致。在校验签名时,为了加速校验速度,Tinker只校验 *_meta.txt文件,然后再根据meta文件中的md5校验其他文件。
其中,meta文件有以下几种:
package_meta.txt 补丁包的基本信息
dex_meta.txt 补丁包中dex文件的信息
so_meta.txt 补丁包中so文件的信息
res_meta.txt 补丁包中资源文件的信息
然后根据开发者配置的Tinker可补丁类型判断是否可以加载dex,res,so。然后分别分发给TinkerDexLoader、TinkerSoLoader、TinkerResourceLoader分别进行校验是否符合加载条件进而进行加载。
(2)加载补丁dex
在开始讲load dex之前,先说下Tinker的补丁方案,Tinker采用的是下发差分包,然后在手机端合成全量的dex文件进行加载。在补丁前dex顺序是这样的:oldDex1 -> oldDex2 -> oldDex3..,那么假如修改了dex1中的文件,那么补丁顺序是这样的newDex1 -> oldDex1 -> oldDex2...其中合成后的newDex1中的类是oldDex1中除了dex.loader中标明的类之外的所有类,dex.loader中的类依然在oldDex1中。
由于Tinker的方案是基于Multidex实现的修改dexElements的顺序实现的,所以最终还是要修改classLoder中dexPathList中dexElements的顺序。Android中有两种ClassLoader用于加载dex文件,BootClassLoader、PathClassLoader和DexClassLoader都是继承自BaseDexClassLoader
public BaseDexClassLoader(String dexPath, File optimizedDirectory,
String libraryPath, ClassLoader parent) {
super(parent);
this.originalPath = dexPath;
this.pathList =
new DexPathList(this, dexPath, libraryPath, optimizedDirectory);
}
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
Class clazz = pathList.findClass(name);
if (clazz == null) {
throw new ClassNotFoundException(name);
}
return clazz;
}
//DexPathList
public Class findClass(String name) {
for (Element element : dexElements) {
DexFile dex = element.dexFile;
if (dex != null) {
Class clazz = dex.loadClassBinaryName(name, definingContext);
if (clazz != null) {
return clazz;
}
}
}
return null;
}
最终在DexPathList的findClass中遍历dexElements,谁在前面用谁。而这个dexElements是在方法makeDexElements中生成的,我们的目的就是hook这个方法把dex插入到dexElements的前面。
继续加载流程,首先调用TinkerDexLoader的checkComplete校验dex_meta.xml文件中记载的dex补丁文件和经过opt优化过的文件是否存在,然后调用loadTinkerJars加载补丁dex。
//SystemClassLoaderAdder
public static void installDexes(Application application, PathClassLoader loader, File dexOptDir, List<File> files)
throws Throwable {
if (!files.isEmpty()) {
ClassLoader classLoader = loader;
if (Build.VERSION.SDK_INT >= 24) {
classLoader = AndroidNClassLoader.inject(loader, application);
}
//because in dalvik, if inner class is not the same classloader with it wrapper class.
//it won't fail at dex2opt
if (Build.VERSION.SDK_INT >= 23) {
V23.install(classLoader, files, dexOptDir);
} else if (Build.VERSION.SDK_INT >= 19) {
V19.install(classLoader, files, dexOptDir);
} else if (Build.VERSION.SDK_INT >= 14) {
V14.install(classLoader, files, dexOptDir);
} else {
V4.install(classLoader, files, dexOptDir);
}
if (!checkDexInstall()) {
throw new TinkerRuntimeException(ShareConstants.CHECK_DEX_INSTALL_FAIL);
}
}
}
.install热修复是把dex插到dexElements的前面,
14 <= SDK < 19
Android 4.0 <= Android系统 < Android 4.4
/**
* Installer for platform versions 14, 15, 16, 17 and 18.
*/
private static final class V14 {
private static void install(ClassLoader loader, List<File> additionalClassPathEntries,
File optimizedDirectory)
throws IllegalArgumentException, IllegalAccessException,
NoSuchFieldException, InvocationTargetException, NoSuchMethodException {
Field pathListField = ShareReflectUtil.findField(loader, "pathList");
Object dexPathList = pathListField.get(loader);
ShareReflectUtil.expandFieldArray(dexPathList, "dexElements", makeDexElements(dexPathList,
new ArrayList<File>(additionalClassPathEntries), optimizedDirectory));
}
private static Object[] makeDexElements(
Object dexPathList, ArrayList<File> files, File optimizedDirectory)
throws IllegalAccessException, InvocationTargetException,
NoSuchMethodException {
Method makeDexElements =
ShareReflectUtil.findMethod(dexPathList, "makeDexElements", ArrayList.class, File.class);
return (Object[]) makeDexElements.invoke(dexPathList, files, optimizedDirectory);
}
}
反射找到classLoder中的pathList,然后反射调用pathList中的makeDexElements方法,穿进去的参数分别是补丁dexList和优化过的opt目录,在Tinker中是dex补丁目录的同级目录odex/。
/**
* Replace the value of a field containing a non null array, by a new array containing the
* elements of the original array plus the elements of extraElements.
*
* @param instance the instance whose field is to be modified.
* @param fieldName the field to modify.
* @param extraElements elements to append at the end of the array.
*/
public static void expandFieldArray(Object instance, String fieldName, Object[] extraElements)
throws NoSuchFieldException, IllegalArgumentException, IllegalAccessException {
Field jlrField = findField(instance, fieldName);
Object[] original = (Object[]) jlrField.get(instance);
Object[] combined = (Object[]) Array.newInstance(original.getClass().getComponentType(), original.length + extraElements.length);
// NOTE: changed to copy extraElements first, for patch load first
System.arraycopy(extraElements, 0, combined, 0, extraElements.length);
System.arraycopy(original, 0, combined, extraElements.length, original.length);
//刚才我们说Tinker是将dex前置,Multidex是将dex后置,Multidex.install()中expandFieldArray的实现把上面连个函数顺序对调
jlrField.set(instance, combined);
}
注意传进来的值分别是pathList,”dexElements”和新生成的dexElements数组,找到pathList的原始oldDexElements,然后生成一个新的数组combined,长度是oldDexElements.length + newDexElements.length。然后将newDexElements拷贝到combined的前面,将oldDexElements拷贝的combined的剩余位置,我们称之为dex前置。
注意v24,SDK >=24,Android 系统 >= Android7.0 是不同的,详细请看
http://mp.weixin.qq.com/s?__biz=MzAwNDY1ODY2OQ==&mid=2649286341&idx=1&sn=054d595af6e824cbe4edd79427fc2706&scene=0
(3)加载补丁资源
Tinker的资源更新采用的InstantRun的资源补丁方式,全量替换资源。由于App加载资源是依赖Context.getResources()方法返回的Resources对象,Resources 内部包装了 AssetManager,最终由 AssetManager 从 apk 文件中加载资源。我们要做的就是新建一个AssetManager(),hook掉其中的addAssetPath()方法,将我们的资源补丁目录传递进去,然后循环替换Resources对象中的AssetManager对象,达到资源替换的目的。
首先依然先根据res_meta.xml文件中记载的信息检查文件(res/resources.apk)是否存在,实现在TinkerResourceLoader.checkComplete()方法,然后调用TinkerResourcePatcher.isResourceCanPatch(context);判断是否支持反射更新资源。InstantRun的资源更新方式最简便而且兼容性也最好,市面上大多数的热补丁框架都采用这套方案。Tinker的这套方案虽然也采用全量的替换,但是在下发patch中依然采用差量资源的方式获取差分包,下发到手机后再合成全量的资源文件,有效的控制了补丁文件的大小。
(4)加载补丁so
依然根据so_meta.txt中的补丁信息校验so文件是否都存在。然后将so补丁列表存放在结果中libs的字段.
so的更新方式跟dex和资源都不太一样,因为系统提供给了开发者自定义so目录的选项。Tinker加载SO补丁提供了两个入口,分别是TinkerInstaller和TinkerApplicationHelper。他们两个的区别是TinkerInstaller只有在Tinker.install过之后才能使用,否则会抛出异常。
/**
* sample usage for native library
*
* @param context
* @param relativePath such as lib/armeabi
* @param libName for the lib libTest.so, you can pass Test or libTest, or libTest.so
* @return boolean
* @throws UnsatisfiedLinkError
*/
public static boolean loadLibraryFromTinker(Context context, String relativePath, String libName) throws UnsatisfiedLinkError {
final Tinker tinker = Tinker.with(context);
libName = libName.startsWith("lib") ? libName : "lib" + libName;
libName = libName.endsWith(".so") ? libName : libName + ".so";
String relativeLibPath = relativePath + "/" + libName;
//TODO we should add cpu abi, and the real path later
if (tinker.isEnabledForNativeLib() && tinker.isTinkerLoaded()) {
TinkerLoadResult loadResult = tinker.getTinkerLoadResultIfPresent();
if (loadResult.libs != null) {
for (String name : loadResult.libs.keySet()) {
if (name.equals(relativeLibPath)) {
String patchLibraryPath = loadResult.libraryDirectory + "/" + name;
File library = new File(patchLibraryPath);
if (library.exists()) {
//whether we check md5 when load
boolean verifyMd5 = tinker.isTinkerLoadVerify();
if (verifyMd5 && !SharePatchFileUtil.verifyFileMd5(library, loadResult.libs.get(name))) {
tinker.getLoadReporter().onLoadFileMd5Mismatch(library, ShareConstants.TYPE_LIBRARY);
} else {
System.load(patchLibraryPath);
TinkerLog.i(TAG, "loadLibraryFromTinker success:" + patchLibraryPath);
return true;
}
}
}
}
}
}
return false;
}
简单来说就是遍历检查的结果列表libs,找到要加载的类,调用System.load方法进行加载。
(5)合成patch
拷贝patch文件拷贝至私有目录,然后调用DexDiffPatchInternal.tryRecoverDexFiles,核心代码主要在extractDexDiffInternals中:
private static boolean extractDexDiffInternals(Context context, String dir, String meta, File patchFile, int type) {
//parse meta
ArrayList<ShareDexDiffPatchInfo> patchList = new ArrayList<>();
ShareDexDiffPatchInfo.parseDexDiffPatchInfo(meta, patchList);
File directory = new File(dir);
//I think it is better to extract the raw files from apk
Tinker manager = Tinker.with(context);
ZipFile apk = null;
ZipFile patch = null;
ApplicationInfo applicationInfo = context.getApplicationInfo();
String apkPath = applicationInfo.sourceDir; //base.apk
apk = new ZipFile(apkPath);
patch = new ZipFile(patchFile);
for (ShareDexDiffPatchInfo info : patchList) {
final String infoPath = info.path;
String patchRealPath;
if (infoPath.equals("")) {
patchRealPath = info.rawName;
} else {
patchRealPath = info.path + "/" + info.rawName;
}
File extractedFile = new File(dir + info.realName);
ZipEntry patchFileEntry = patch.getEntry(patchRealPath);
ZipEntry rawApkFileEntry = apk.getEntry(patchRealPath);
patchDexFile(apk, patch, rawApkFileEntry, patchFileEntry, info, extractedFile);
}
return true;
}
这里的代码比较关键了,可以看出首先解析了meta里面的信息,meta中包含了patch中每个dex的相关数据。然后通过Application拿到sourceDir,其实就是本机apk的路径以及patch文件;根据mate中的信息开始遍历,其实就是取出对应的dex文件,最后通过patchDexFile对两个dex文件做合并。
private static void patchDexFile(
ZipFile baseApk, ZipFile patchPkg, ZipEntry oldDexEntry, ZipEntry patchFileEntry,
ShareDexDiffPatchInfo patchInfo, File patchedDexFile) throws IOException {
InputStream oldDexStream = null;
InputStream patchFileStream = null;
oldDexStream = new BufferedInputStream(baseApk.getInputStream(oldDexEntry));
patchFileStream = (patchFileEntry != null ? new BufferedInputStream(patchPkg.getInputStream(patchFileEntry)) : null);
new DexPatchApplier(oldDexStream, patchFileStream).executeAndSaveTo(patchedDexFile);
}
通过ZipFile拿到其内部文件的InputStream,其实就是读取本地apk对应的dex文件,以及patch中对应dex文件,对二者的通过executeAndSaveTo方法进行合并至patchedDexFile,即patch的目标私有目录。至于合并算法,这里其实才是tinker比较核心的地方,这个算法跟dex文件格式紧密关联.
https://www.zybuluo.com/dodola/note/554061