文章目錄
1 熱修復技術出現的背景
要說到熱修復的出現,主要還是因爲在中國Android應用沒有一個統一的應用商店,每個手機廠商基本都會定製自己的一款手機應用市場,不像國外的統一都使用Google Play。
中國用戶下載app都在不同的應用市場下載,沒有統一的應用市場導致的問題就是,如果應用出現bug需要緊急修復,按正常的發佈流程,需要從bug的修改、發佈應用到各個應用市場、提示用戶下載安裝修復這幾個流程。
在流程上看,有一些弊端:
-
需要重新發布版本代價太大(可能只是修改小bug)
-
用戶下載安裝覆蓋成本太高
-
bug修復不及時導致給用戶的體驗差
出現了以上問題,所以在中國熱修復技術就產生了。
2 熱修復技術簡介
什麼是熱修復?熱修復簡單來說就是一種補丁方案,我們只需要通過將補丁文件打包爲 .dex
推送給用戶,結合類加載機制在應用重啓的時候就可以修復好出現的bug。
優勢上是顯而易見的:
-
無需重新發布版本,實時高效修復
-
用戶無感知,無需下載安裝覆蓋應用,代價小
-
修復成功率比較高
目前在市面上熱修復技術有阿里巴巴的 AndFix
、Dexposed
,騰訊QQ空間的超級補丁和微信的 Tinker
等。
這些熱修復技術框架的使用我不會在這裏講,而是要了解熱修復技術的底層實現原理。
3 插件化
在知道熱修復之前,你或許有聽說過插件化。那什麼是插件化?
3.1 什麼是插件化
如果你有關注一些比較大型的app,比如支付寶、美團等,可以發現app的一些入口是一個完全不同的功能,這些功能可能是主項目發佈後在後期動態加入進來的,而這種將其他apk集成到另一個apk的技術就是插件化。插件化的核心就是動態部署。
要注意的是,下面說的插件化方式也不是官方提供也不提倡的,和熱修復一樣在中國比較合適,要和Android提供的 Android App Bundles
區分。
3.2 插件化例子
現在我們實現一個功能:在我們的apk中通過插件化集成另一個apk,應用啓動的時候讓集成進來的apk生效(因爲是demo,所以我們直接就在項目中創建另一個apk)。
- 創建一個插件名爲
plugin
(注意這裏我們是選擇Phone & Tablet Module
模擬一個插件apk),apk裏面寫一個類:
package com.example.plugin;
public class PluginClass {
public String plugin() {
return "I'm a plugin!";
}
}
- 生成apk,將這個apk放到主項目的
assets
目錄下(當然,實際的項目可能會從網絡下載或其他方式獲取插件apk):
- 將apk加載進來:
那麼你可能會有疑問了:外部apk我們怎麼將它作爲插件加入到我們主項目中來呢?我們知道android的apk打包後都有一個或多個dex文件,而dex文件裏面是我們java文件的 .class
字節碼文件:
既然dex文件是我們一些 .class
字節碼文件,那麼同樣的也需要類加載器來加載,就是 DexClassLoader
,我們可以通過它來獲取加載我們的apk中的那個 PluginClass
類。
public class MainActivity extends AppCompatActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
File pluginApk = new File(getCacheDir() + "/plugin.apk");
if (!pluginApk.exists()) {
try(Source source = Okio.source(getAssets().open("plugin-debug.apk"));
BufferedSink sink = Okio.buffer(Okio.sink(pluginApk))) {
sink.writeAll(source);
} catch (IOException e) {
e.printStackTrace();
}
}
DexClassLoader dexClassLoader = new DexClassLoader(pluginApk.getPath(), getCacheDir().getPath(), null, null);
String pluginPrint = "";
try {
Class<?> clazz = dexClassLoader.loadClass("com.example.plugin.PluginClass");
Constructor<?> constructor = clazz.getDeclaredConstructor();
Object pluginObj = constructor.newInstance();
Method pluginMethod = clazz.getDeclaredMethod("plugin");
pluginPrint = (String) pluginMethod.invoke(pluginObj);
} catch (ClassNotFoundException e) {
e.printStackTrace();
} catch (NoSuchMethodException e) {
e.printStackTrace();
} catch (IllegalAccessException e) {
e.printStackTrace();
} catch (InstantiationException e) {
e.printStackTrace();
} catch (InvocationTargetException e) {
e.printStackTrace();
}
((TextView) findViewById(R.id.tv_plugin_test)).setText(pluginPrint);
}
}
上面的代碼非常簡單,就是讀取我們 assets
目錄的apk文件到本地,然後使用 DexClassLoader
將apk加載後使用反射調用。
3.3 新增界面、資源的插件
既然上面是通過反射才能夠拿到數據展示,那麼如果我的插件apk可能有界面Activity的怎麼辦?直接使用 Intent
跳轉?肯定是行不通的,運行時會提示清單文件沒有註冊Activity。那要怎麼做?可以提供代理Activity讓它轉發處理到插件apk的界面:
// 僞代碼
public class ProxyActivity extends AppCompatActivity {
Object pluginActivity = DexClassLoader.loadClass("xxx");
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
pluginActivity.onCreate(savedInstanceState);
}
@Override
protected void onStart() {
super.onStart();
pluginActivity.onStart();
}
...
}
不過上面的方式也會導致要寫很多的代理類。
還有另外一種方式就是通過欺騙系統,在系統檢查完清單文件發現Activity註冊後,在啓動清單文件註冊的Activity之前替換爲我們要啓動的Activity。不過這種方式說不準往後會被官方屏蔽也說不定,只使用於當前。
而新增在插件apk的資源又要怎麼獲取?需要重寫 getResources()
擴展:
@Override
public Resources getResources() {
return new Resources(createAssetManager("插件apk本地目錄"),
super.getResources().getDisplayMetrics(),
super.getResources().getConfiguration());
}
private AssetManager createAssetManager(String dexPath) {
try {
// addAssetPath()是AssetManager的方法
AssetManager am = AssetManager.class.newInstance();
Method addAssetPath = am.getClass().getMethod("addAssetPath", String.class);
addAssetPath.invoke(am, dexPath);
return sm;
} catch (Exception e) {
e.printStackTrace();
}
return null;
}
4 熱修復技術
熱修復技術主要是處理我們上線發佈的版本需要緊急對bug進行修復,可能只是一些小改動,如果使用上面說的插件化的方式就不行了,插件化會替換掉所有的內容,這顯然不符合我們預期,我們只想要替換掉修復bug更改的一個或幾個java文件。
4.1 類加載機制
因爲熱修復技術需要了解類加載機制和反射相關原理和使用,所以首先還是需要有一個基本瞭解。在之前我寫了一篇文章可以作爲參考:
4.2 PathClassLoader和DexClassLoader
在Android中主要涉及有幾個類加載器:
-
PathClassLoader
-
DexClassLoader
-
BaseDexClassLoader
PathClassLoader
和 DexClassLoader
都是 BaseDexClassLoader
的子類:
public class PathClassLoader extends BaseDexClassLoader {
public PathClassLoader(String dexPath, ClassLoader parent) {
super(dexPath, null, null, parent);
}
public PathClassLoader(String dexPath, String librarySearchPath, ClassLoader parent) {
super(dexPath, null, librarySearchPath, parent);
}
}
public class DexClassLoader extends BaseDexClassLoader {
public DexClassLoader(String dexPath, String optimizedDirectory, String librarySearchPath, ClassLoader parent) {
super(dexPath, new File(optimizedDirectory), librarySearchPath, parent);
}
}
根據上面的源碼可以發現,PathClassLoader
和 DexClassLoader
的區別在於,DexClassLoader
多了一個 optimizedDirectory
參數,optimizedDirectory
可以是apk外部的文件目錄路徑。
這產生的具體區別就是,DexClassLoader
可用於加載指定路徑下的 .dex
文件,能支持動態插件化加載、熱修復;PathClassLoader
就只能用於加載已經安裝到系統中的apk文件中的 .dex
文件,不能從外部加載,也就不支持動態插件化加載、熱修復。
4.3 熱修復技術的原理
4.3.1 findClass()
上面分析了 PathClassLoader
和 DexClassLoader
的區別,發現它們都是 BaseDexClassLoader
的子類,具體的熱修復原理分析也是在這裏說明。
熱修復干預類加載機制是在 ClassLoader.findClass()
處理:
BaseDexClassLoader.java
public class BaseDexClassLoader {
private DexPathList pathList;
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
...
Class c = pathList.findClass(name, suppressedExceptions);
...
}
}
DexPathList.java
public class DexPathList {
private Element[] dexElements;
public Class<?> findClass(String name, List<Throwable> suppressed) {
for (Element element : dexElements) {
Class<?> clazz = element.findClass(name, definingContext, suppressed);
if (clazz != null) {
return clazz;
}
...
return null;
}
}
}
Element.java
public class Element {
public Class<?> findClass(String name, ClassLoader definingContext, List<Throwable> suppressed) {
return dexFile != null ? dexFile.loadClassBinaryName(name, definingContext, suppressed) : null;
}
}
DexFile.java
public final class DexFile {
public Class loadClassBinaryName(String name, ClassLoader loader, List<Throwable> suppressed) {
return defineClass(name, loader, mCookie, this, suppressed);
}
private static class defineClass(String name, ClassLoader loader, Object cookie, DexFile dexFile, List<Throwable> suppressed) {
Class result = null;
try {
// native method
result = defineClassNative(name, loader, cookie, dexFile);
} catch (NoClassDefFoundError e) {
...
} catch (ClassNotFoundException e) {
...
}
return result;
}
}
上面的流程先簡單梳理一下:
BaseDexClassLoader.findClass()
-> DexPathList.findClass()
-> Element.findClass()
-> DexFile.loadClassBinaryName()
-> DexFile.defineClass()
但是有幾個點我們沒有搞明白:DexPathList
是什麼?dexElements
是什麼?dexFile
又是什麼?接下來一步步分析。
4.3.2 DexPathList、dexElement、dexFile
首先看下 DexPathList
是什麼時候被初始化的:
public class BaseDexClassLoader {
private DexPathList pathList;
public BaseDexClassLoader(String dexPath, File optimizedDirectory, String librarySearchPath, ClassLoader parent) {
this(dexPath, librarySearchPath, parent, null, false);
}
public BaseDexClassLoader(String dexPath, String librarySearchPath, ClassLoader parent, ClassLoader[] sharedLibraryLoaders, boolean isTrusted) {
super(parent);
...
// DexPathList在ClassLoader創建的時候被初始化
this.pathList = new DexPathList(this, dexPath, librarySearchPath, null, isTrusted);
...
}
}
可以發現,DexPathList
是在 BaseDexClassLoader
創建的時候初始化,進去 DexPathList
:
public class DexPathList {
private Element[] dexElements;
DexPathList(ClassLoader definingContext, String dexPath, String librarySearchPath, File optimizedDirectory, boolean isTrusted) {
...
this.definingContext = definingContext;
// dexElements在DexPathList初始化的時候完成加載dex文件
this.dexElements = makeDexElements(splitDexPath(dexPath), optimizedDirectory, suppressedException, definingContext, isTrusted);
}
private static List<File> splitDexPath(String path) {
return splitPaths(path, false);
}
private static List<File> splitPaths(String searchPath, boolean directoryOnly) {
List<File> result = new ArrayList<>();
if (searchPath != null) {
// File.pathSeparator在不同的系統是不一樣的
// Windows系統:File.pathSeparator=";"
// Unix、Linux系統:File.pathSeparator=":"
// 這是系統環境變量Path分割拼接的路徑
// 這裏的操作就是將我們的apk的dex文件目錄做拆分
// new DexClassLoader(path, ...)
// 也就是path可以是單個文件,也可以是一個目錄,根據不同的操作系統對目錄路徑拆分
for (String path : searchPath.split(File.pathSeparator)) {
if (directoryOnly) {
try {
StructStat sb = Libcore.os.stat(path);
if (!S_ISDIR(sb.st_mode)) {
continue;
}
} catch (ErrnoException ignored) {
continue;
}
}
result.add(new File(path));
}
}
return result;
}
// 加載dex文件存儲到dexElements數組中
private static Element[] makeDexElements(List<File> fiels, File optimizedDirectory, List<IOException> suppressedExceptions, ClassLoader loader, boolean isTrusted) {
Element[] elements = new Element[files.size()];
int elementsPos = 0;
for (File file : files) {
if (file.isDirectory()) {
elements[elementsPos++] = new Element(file);
} else if (file.isFile()) {
String name = file.getName();
DexFile dex = null;
// DEX_SUFFIX = ".dex"
// 如果是dex文件就加載,並且裝進Element存到dexElements
if (name.endsWith(DEX_SUFFIX)) {
try {
dex = loadDexFile(file, optimizedDirectory, loader, elements);
if (dex != null) {
elements[elementsPos++] = new Element(dex, null);
}
} catch (IOException suppressed) {
...
}
} else {
try {
// 不是dex文件同樣也處理加載
dex = loadDexFile(file, optimizedDirectory, loader, elements);
} catch (IOException suppressed) {
...
}
// 無論是否爲dex文件都裝載進Element存到dexElements
if (dex == null) {
elements[elementsPos++] = new Element(file);
} else {
elements[elementsPos++] = new Element(dex, file);
}
}
}
}
...
}
private static DexFile loadDexFile(File file, File optimizedDirectory, ClassLoader loader, Element[] elements) throws IOException {
if (optimizedDirectory == null) {
return new DexFile(file, loader, elements);
} else {
String optimizedPath = optimizedPathFor(file, optimizedDirectory);
return DexFile.loadDex(file.getPath(), optimizedPath, 0, loader, elements);
}
}
// 這個方法就是處理,如果不是dex文件,就幫你的文件加上後綴.dex
private static String optimizedPathFor(File path, File optimizedDirectory) {
String fileName = path.getName();
if (!fileName.endsWith(DEX_SUFFIX)) {
int lastDot = fileName.lastIndexOf(".");
if (lastDot < 0) {
fileName += DEX_SUFFIX;
} else {
StringBuilder sb = new StringBuilder(lastDot + 4);
sb.append(fileName, 0, lastDot);
sb.append(DEX_SUFFIX);
fileName = sb.toString();
}
}
return fileName;
}
}
通過上面的源碼分析,dexElements
其實就是我們存放的 .dex
文件的數組,只不過將 .dex
封裝進Element。它同樣在ClassLoader創建出來的時候,將我們的文件通過 dexFile
加載出來。
4.4 分析干預類加載
經過上面的分析我們知道,既然主要的 .dex
加載完成的文件是存放在 dexElements
數組中,那麼它就是一個切入點。
有兩種方式可以干預:
-
全量替換
dexElements
數組 -
將我們修改的類文件先轉成dex文件,然後自己封裝放進Element,再將這個Element插入到
dexElements
數組前面
我們用一個例子來干預類加載達到熱修復:提供兩個按鈕,點擊 hotfix
按鈕替換 dexElements
,點擊 show text
按鈕顯示熱修復後的內容。
4.4.1 全量替換dexElement數組
// 未熱修復前的類
public class HotfixClass {
public String hotfix() {
return "I'm a original!";
}
}
// 熱修復後的類
public class HotfixClass {
public String hotfix() {
return "I'm a hotfix!";
}
}
public class MainActivity extends AppCompatActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
final TextView tvText = findViewById(R.id.tv_text);
Button btnShowText = findViewById(R.id.btn_show_text);
Button btnHotFix = findViewById(R.id.btn_hotfix);
btnShowText.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
HotfixClass hotfixClass = new HotfixClass();
tvText.setText(hotfixClass.hotfix());
}
});
btnHotFix.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
File hotfixFile = new File(getCacheDir() + "/hotfix.apk");
try(Source source = Okio.source(getAssets().open("app-debug.apk"));
BufferedSink sink = Okio.buffer(Okio.sink(hotfixFile))) {
sink.writeAll(source);
} catch (IOException e) {
e.printStackTrace();
}
try {
ClassLoader classLoader = getClassLoader();
Class<BaseDexClassLoader> loaderClass = BaseDexClassLoader.class;
// 獲取ClassLoader的DexPathList pathList成員變量
Field pathListFiled = loaderClass.getDeclaredField("pathList");
pathListFiled.setAccessible(true);
Object pathList = pathListFiled.get(classLoader);
// 獲取DexPathList的Element[] dexElements成員變量
Class<?> pathListClass = pathList.getClass();
Field dexElementsField = pathListClass.getDeclaredField("dexElements");
dexElementsField.setAccessible(true);
// 創建我們的類加載器,加載出我們熱修復需要的dexElements數組,替換舊的ClassLoader的dexElements
DexClassLoader hotfixClassLoader = new DexClassLoader(hotfixFile.getPath(), getCacheDir().getPath(), null, null);
Object newPathList = pathListFiled.get(hotfixClassLoader);
Object newDexElements = dexElementsField.get(newPathList);
dexElementsField.set(pathList, newDexElements);
} catch (NoSuchFieldException e) {
e.printStackTrace();
} catch (IllegalAccessException e) {
e.printStackTrace();
}
}
});
}
}
上面代碼操作步驟如下:
-
通過反射獲取
BaseDexClassLoader
裏面的pathList
成員變量 -
再通過
pathList
反射獲取dexElements
成員變量 -
自己創建一個ClassLoader加載我們自己的補丁文件生成
dexElements
-
替換舊的
dexElements
4.4.2 存在的問題
看起來沒問題也運行正常,但實際上有一些弊端:
-
如果把程序kill掉,重新啓動apk後直接點擊
show text
熱更新失效了(因爲類加載機制這時候加載的是你沒修改的那個類) -
我爲了修復這個bug,替換了整個apk而不是隻替換要修復的
HotfixClass
類 -
熱更新要生效,需要重啓apk(要讓熱更新生效只能如此)
4.4.2.1 解決程序kill掉後熱修復失效問題
第二個問題是因爲沒有及時的將我們熱修復的補丁文件加到 dexElements
中,可以將它提前到 Application.attachBaseContext()
執行:
public class HotfixApplication extends Application {
@Override
protected void attachBaseContext(Context base) {
super.attachBaseContext(base);
File hotfixFile = new File(getCacheDir() + "/hotfix.apk");
if (!hotfixFile.exists()) {
return;
}
try {
ClassLoader classLoader = getClassLoader();
Class<BaseDexClassLoader> loaderClass = BaseDexClassLoader.class;
// 獲取ClassLoader的DexPathList pathList成員變量
Field pathListFiled = loaderClass.getDeclaredField("pathList");
pathListFiled.setAccessible(true);
Object pathList = pathListFiled.get(classLoader);
// 獲取DexPathList的Element[] dexElements成員變量
Class<?> pathListClass = pathList.getClass();
Field dexElementsField = pathListClass.getDeclaredField("dexElements");
dexElementsField.setAccessible(true);
// 創建我們的類加載器,加載出我們熱修復需要的dexElements數組,替換舊的ClassLoader的dexElements
DexClassLoader hotfixClassLoader = new DexClassLoader(hotfixFile.getPath(), getCacheDir().getPath(), null, null);
Object newPathList = pathListFiled.get(hotfixClassLoader);
Object newDexElements = dexElementsField.get(newPathList);
dexElementsField.set(pathList, newDexElements);
} catch (NoSuchFieldException e) {
e.printStackTrace();
} catch (IllegalAccessException e) {
e.printStackTrace();
}
}
}
4.4.2.2 解決替換整個apk而不是替換修復的類文件問題
第一個問題的解決方案就是我們只將我們要修改的類編譯爲 .dex
文件,然後插入到 dexElements
前面。
將 .class
文件編譯爲 .dex
需要使用到 d8
工具,這個工具存放在我們的sdk build-tools/xxx版本/d8
:
-
將
HotfixClass.java
使用命令javac HotfixClass.java
編譯爲HotfixClass.class
-
將
HotfixClass.class
轉成.dex
文件,執行命令:
// Windows
d8.bat HotfixClass.class
// Unix、Linux
./d8 HotfixClass.class
輸出:classes.dex
- 使用
classes.dex
作爲熱修復的補丁文件
因爲修改的是一個 .dex
文件,如果還是使用上面的方案去全量替換 dexElements
將會導致應用崩潰,因爲你是把整個apk替換稱這個只有一個補丁 .dex
文件。
所以也就需要第二種熱修復方案。
4.4.3 修改文件轉成dex文件封裝爲Element插入到dexElement數組前面
public class HotfixApplication extends Application {
@Override
protected void attachBaseContext(Context base) {
super.attachBaseContext(base);
File hotfixFile = new File(getCacheDir() + "/hotfix.dex");
if (!hotfixFile.exists()) {
return;
}
try {
ClassLoader classLoader = getClassLoader();
Class<BaseDexClassLoader> loaderClass = BaseDexClassLoader.class;
// 獲取ClassLoader的DexPathList pathList成員變量
Field pathListFiled = loaderClass.getDeclaredField("pathList");
pathListFiled.setAccessible(true);
Object pathList = pathListFiled.get(classLoader);
// 獲取DexPathList的Element[] dexElements成員變量
Class<?> pathListClass = pathList.getClass();
Field dexElementsField = pathListClass.getDeclaredField("dexElements");
dexElementsField.setAccessible(true);
Object dexElements = dexElementsField.get(pathList);
// 創建我們的類加載器,加載出我們熱修復需要的dexElements數組
DexClassLoader hotfixClassLoader = new DexClassLoader(hotfixFile.getPath(), getCacheDir().getPath(), null, null);
Object newPathList = pathListFiled.get(hotfixClassLoader);
Object newDexElements = dexElementsField.get(newPathList);
int oldLength = Array.getLength(dexElements);
int newLength = Array.getLength(newDexElements);
Object concatDexElements = Array.newInstance(dexElements.getClass().getComponentType(), oldLength + newLength);
for (int i = 0; i < newLength; i++) {
Array.set(concatDexElements, i, Array.get(newDexElements, i));
}
for (int i = 0; i < oldLength; i++) {
Array.set(concatDexElements, newLength + i, Array.get(dexElements, i));
}
dexElementsField.set(pathList, concatDexElements);
} catch (NoSuchFieldException e) {
e.printStackTrace();
} catch (IllegalAccessException e) {
e.printStackTrace();
}
}
}
通過將一個或多個需要修復的類文件打包成 .dex
文件的方式,在實際的項目開發當中要使用到熱修復,我們就可以將我們要修改的補丁文件打成 .dex
文件,通過網絡的方式推給apk,讓apk在下次啓動的時候生效。
需要注意的是,我們自己熱修復的 .dex
文件封裝的Element要插入 dexElements
數組前面而不是後面。
首先第一個原因就是 dexElements
是順序遍歷循環的:
public class DexPathList {
private Element[] dexElements;
public Class<?> findClass(String name, List<Throwable> suppressed) {
// 順序循環遍歷
for (Element element : dexElements) {
Class<?> clazz = element.findClass(name, definingContext, suppressed);
if (clazz != null) {
return clazz;
}
...
return null;
}
}
}
第二個原因是因爲類加載機制在首次加載到這個類後,下一次獲取會去查找緩存那個之前已加載過的類:
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException
{
// 第二次及往後查找緩存獲取c != null,直接就會返回
Class<?> c = findLoadedClass(name);
if (c == null) {
try {
if (parent != null) {
c = parent.loadClass(name, false);
} else {
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// ClassNotFoundException thrown if class not found
// from the non-null parent class loader
}
if (c == null) {
// 第一次加載會遍歷到dexElements
c = findClass(name);
}
}
return c;
}
5 總結
5.1 熱修復原理的簡單說明
簡單來說,熱修復的原理就是:
-
ClassLoader的dex文件替換
-
直接修改字節碼
5.2 初始化流程和從dex文件查找類流程
初始化流程:
創建 BaseDexClassLoader
-> 創建 DexPathList
-> DexPathList.splitDexPath()
獲取dex文件 -> DexPathList.makeDexElements()
加載dex文件封裝爲Element存儲到 dexElements
數組
從dex文件查找類流程:
BaseDexClassLoader.findClass()
-> DexPathList.findClass()
-> 遍歷 dexElements
調用 Element.findClass()
-> DexFile.loadClassBinaryName()
-> DexFile.defineClass()
5.3 插件化和熱修復的區別
區別有兩點:
-
插件化的內容在原App中沒有,而熱修復是在原App中的內容做改動
-
插件化在代碼中有固定的入口,而熱修復則可能改變任何一個位置的代碼