前言
熱修復(fù)
到現(xiàn)在2022年已經(jīng)不是一個新名詞黄虱,但是作為Android開發(fā)核心技術(shù)棧的一部分,我這里還得來一次冷飯熱炒庸诱。
隨著移動端業(yè)務(wù)復(fù)雜程度的增加捻浦,傳統(tǒng)的版本更新流程顯然無法滿足業(yè)務(wù)和開發(fā)者的需求晤揣, 熱修復(fù)技術(shù)的推出在很大程度上改善了這一局面。國內(nèi)大部分成熟的主流 App都擁有自己的熱更新技術(shù)朱灿,像手淘昧识、支付寶、微信盗扒、QQ滞诺、餓了么、美團等环疼。
可以說习霹,一個好的熱修復(fù)技術(shù),將為你的 App助力百倍炫隶。對于每一個想在 Android 開發(fā)領(lǐng)域有所造詣的開發(fā)者淋叶,掌握熱修復(fù)技術(shù)更是必備的素質(zhì)。
熱修復(fù)
是 Android 大廠面試中高頻面試知識點伪阶,也是我們必須要掌握的知識點煞檩。熱修復(fù)技術(shù),可以看作 Android平臺發(fā)展成熟至一定階段的必然產(chǎn)物栅贴。 Android熱修復(fù)了解嗎斟湃?修復(fù)哪些東西? 常見熱修復(fù)框架對比以及各原理分析檐薯?
1.什么是熱修復(fù)
熱修復(fù)說白了就是不再使用傳統(tǒng)的應(yīng)用商店更新或者自更新方式凝赛,使用補丁包推送的方式在用戶無感知的情況下,修復(fù)應(yīng)用bug或者推送新的需求
傳統(tǒng)更新
和熱更新
過程對比如下:
熱修復(fù)優(yōu)缺點
:
-
優(yōu)點:
- 1.只需要打補丁包坛缕,不需要重新發(fā)版本墓猎。
- 2.用戶無感知,不需要重新下載最新應(yīng)用
- 3.修復(fù)成功率高
-
缺點:
- 補丁包濫用赚楚,容易導(dǎo)致應(yīng)用版本不可控毙沾,需要開發(fā)一套完整的補丁包更新機制,會增加一定的成本
2.熱修復(fù)方案
首先我們得知道熱修復(fù)修復(fù)哪些東西宠页?
- 1.代碼修復(fù)
- 2.資源修復(fù)
- 3.動態(tài)庫修復(fù)
2.1:代碼修復(fù)方案
從技術(shù)角度來說左胞,我們的目的是非常明確的:把錯誤的代碼替換成正確的代碼。 注意這里的替換举户,并不是直接擦寫dx文件烤宙,而是提供一份新的正確代碼,讓應(yīng)用運行時繞過錯誤代碼敛摘,執(zhí)行新的正確代碼门烂。
想法簡單直接,但實現(xiàn)起來并不容易。目前主要有三類技術(shù)方案:
2.1.1.類加載方案
之前分析類加載機制有說過: 加載流程先是遵循雙親委派原則屯远,如果委派原則沒有找到此前加載過此類蔓姚, 則會調(diào)用CLassLoader的findClass方法,再去BaseDexClassLoader下面的dexElements數(shù)組中查找慨丐,如果沒有找到坡脐,最終調(diào)用defineClassNative方法加載
代碼修復(fù)就是基于這點: 將新的做了修復(fù)的dex文件,通過反射注入到BaseDexClassLoader的dexElements數(shù)組的第一個位置上dexElements[0]房揭,下次重新啟動應(yīng)用加載類的時候备闲,會優(yōu)先加載做了修復(fù)的dex文件,這樣就達到了修復(fù)代碼的目的捅暴。原理很簡單
代碼如下:
public class Hotfix {
public static void patch(Context context, String patchDexFile, String patchClassName)
throws ClassNotFoundException, NoSuchFieldException, IllegalAccessException, InstantiationException, NoSuchMethodException, InvocationTargetException {
//獲取系統(tǒng)PathClassLoader的"dexElements"屬性值
PathClassLoader pathClassLoader = (PathClassLoader) context.getClassLoader();
Object origDexElements = getDexElements(pathClassLoader);
//新建DexClassLoader并獲取“dexElements”屬性值
String otpDir = context.getDir("dex", 0).getAbsolutePath();
Log.i("hotfix", "otpdir=" + otpDir);
DexClassLoader nDexClassLoader = new DexClassLoader(patchDexFile, otpDir, patchDexFile, context.getClassLoader());
Object patchDexElements = getDexElements(nDexClassLoader);
//將patchDexElements插入原origDexElements前面
Object allDexElements = combineArray(origDexElements, patchDexElements);
//將新的allDexElements重新設(shè)置回pathClassLoader
setDexElements(pathClassLoader, allDexElements);
//重新加載類
pathClassLoader.loadClass(patchClassName);
}
private static Object getDexElements(ClassLoader classLoader) throws ClassNotFoundException, NoSuchFieldException, IllegalAccessException {
//首先獲取ClassLoader的“pathList”實例
Field pathListField = Class.forName("dalvik.system.BaseDexClassLoader").getDeclaredField("pathList");
pathListField.setAccessible(true);//設(shè)置為可訪問
Object pathList = pathListField.get(classLoader);
//然后獲取“pathList”實例的“dexElements”屬性
Field dexElementField = pathList.getClass().getDeclaredField("dexElements");
dexElementField.setAccessible(true);
//讀取"dexElements"的值
Object elements = dexElementField.get(pathList);
return elements;
}
//合拼dexElements
private static Object combineArray(Object obj, Object obj2) {
Class componentType = obj2.getClass().getComponentType();
//讀取obj長度
int length = Array.getLength(obj);
//讀取obj2長度
int length2 = Array.getLength(obj2);
Log.i("hotfix", "length=" + length + ",length2=" + length2);
//創(chuàng)建一個新Array實例恬砂,長度為ojb和obj2之和
Object newInstance = Array.newInstance(componentType, length + length2);
for (int i = 0; i < length + length2; i++) {
//把obj2元素插入前面
if (i < length2) {
Array.set(newInstance, i, Array.get(obj2, i));
} else {
//把obj元素依次放在后面
Array.set(newInstance, i, Array.get(obj, i - length2));
}
}
//返回新的Array實例
return newInstance;
}
private static void setDexElements(ClassLoader classLoader, Object dexElements) throws ClassNotFoundException, NoSuchFieldException, IllegalAccessException {
//首先獲取ClassLoader的“pathList”實例
Field pathListField = Class.forName("dalvik.system.BaseDexClassLoader").getDeclaredField("pathList");
pathListField.setAccessible(true);//設(shè)置為可訪問
Object pathList = pathListField.get(classLoader);
//然后獲取“pathList”實例的“dexElements”屬性
Field declaredField = pathList.getClass().getDeclaredField("dexElements");
declaredField.setAccessible(true);
//設(shè)置"dexElements"的值
declaredField.set(pathList, dexElements);
}
}
類加載過程如下:
微信Tinker
,QQ 空間的超級補丁
蓬痒、手 QQ 的QFix
泻骤、餓了 么的 Amigo
和 Nuwa
等都是使用這個方式
缺點:因為類加載后無法卸載,所以類加載方案必須重啟App梧奢,讓bug類重新加載后才能生效狱掂。
2.1.2:底層替換方案
底層替換方案不會再次加載新類,而是直接在 Native 層 修改原有類亲轨, 這里我們需要提到Art虛擬機中ArtMethod
: 每一個Java方法在Art虛擬機中都對應(yīng)著一個 ArtMethod
趋惨,ArtMethod記錄了這個Java方法的所有信息,包括所屬類惦蚊、訪問權(quán)限器虾、代碼執(zhí)行地址等。
結(jié)構(gòu)如下:
// art/runtime/art_method.h
class ArtMethod FINAL {
...
protected:
GcRoot<mirror::Class> declaring_class_;
GcRoot<mirror::PointerArray> dex_cache_resolved_methods_;
GcRoot<mirror::ObjectArray<mirror::Class>> dex_cache_resolved_types_;
uint32_t access_flags_;
uint32_t dex_code_item_offset_;
uint32_t dex_method_index_;
uint32_t method_index_;
struct PACKED(4) PtrSizedFields {
void* entry_point_from_interpreter_; // 1
void* entry_point_from_jni_;
void* entry_point_from_quick_compiled_code_; //2
} ptr_sized_fields_;
...
}
在 ArtMethod結(jié)構(gòu)體中养筒,最重要的就是 注釋1和注釋2標(biāo)注的內(nèi)容曾撤,從名字可以看出來端姚,他們就是方法的執(zhí)行入口晕粪。 我們知道,Java代碼在Android中會被編譯為 Dex Code渐裸。
Art虛擬機中可以采用解釋模式或者 AOT機器碼模式執(zhí)行 Dex Code
- 解釋模式: 就是去除Dex Code巫湘,逐條解釋執(zhí)行。 如果方法的調(diào)用者是以解釋模式運行的昏鹃,在調(diào)用這個方法時尚氛,就會獲取這個方法的 entry_point_from_interpreter_,然后跳轉(zhuǎn)執(zhí)行洞渤。
- AOT模式: 就會預(yù)先編譯好 Dex Code對應(yīng)的機器碼阅嘶,然后在運行期直接執(zhí)行機器碼,不需要逐條解釋執(zhí)行Dex Code。 如果方法的調(diào)用者是以AOT機器碼方式執(zhí)行的讯柔,在調(diào)用這個方法時抡蛙,就是跳轉(zhuǎn)到 entry_point_from_quick_compiled_code_中執(zhí)行。
那是不是只需要替換這個幾個 entry_point_* 入口地址就能夠?qū)崿F(xiàn)方法替換了呢魂迄? 并沒有那么簡單粗截,因為不論是解釋模式還是AOT模式,在運行期間還會需要調(diào)用ArtMethod中的其他成員字段
AndFix采用的是改變指針指向:
// AndFix/jni/art/art_method_replace_6_0.cpp
void replace_6_0(JNIEnv* env, jobject src, jobject dest) {
art::mirror::ArtMethod* smeth =
(art::mirror::ArtMethod*) env->FromReflectedMethod(src); // 1
art::mirror::ArtMethod* dmeth =
(art::mirror::ArtMethod*) env->FromReflectedMethod(dest); // 2
...
// 3
smeth->declaring_class_ = dmeth->declaring_class_;
smeth->dex_cache_resolved_methods_ = dmeth->dex_cache_resolved_methods_;
smeth->dex_cache_resolved_types_ = dmeth->dex_cache_resolved_types_;
smeth->access_flags_ = dmeth->access_flags_ | 0x0001;
smeth->dex_code_item_offset_ = dmeth->dex_code_item_offset_;
smeth->dex_method_index_ = dmeth->dex_method_index_;
smeth->method_index_ = dmeth->method_index_;
smeth->ptr_sized_fields_.entry_point_from_interpreter_ =
dmeth->ptr_sized_fields_.entry_point_from_interpreter_;
smeth->ptr_sized_fields_.entry_point_from_jni_ =
dmeth->ptr_sized_fields_.entry_point_from_jni_;
smeth->ptr_sized_fields_.entry_point_from_quick_compiled_code_ =
dmeth->ptr_sized_fields_.entry_point_from_quick_compiled_code_;
LOGD("replace_6_0: %d , %d",
smeth->ptr_sized_fields_.entry_point_from_quick_compiled_code_,
dmeth->ptr_sized_fields_.entry_point_from_quick_compiled_code_);
}
缺點:存在一些兼容性問題捣炬,由于ArtMethod結(jié)構(gòu)體是Android開源的一部分熊昌,所以每個手機廠商都可能會去更改這部分的內(nèi)容,這就可能導(dǎo)致ArtMethod替換方案在某些機型上面出現(xiàn)未知錯誤湿酸。
Sophix為了規(guī)避上面的AndFix的風(fēng)險婿屹,采用直接替換整個結(jié)構(gòu)體
。這樣不管手機廠商如何更改系統(tǒng)推溃,我們都可以正確定位到方法地址
2.4.3:install run
方案
Instant Run 方案的核心思想是——插樁选泻,在編譯時通過插樁在每一個方法中插入代碼,修改代碼邏輯美莫,在需要時繞過錯誤方法页眯,調(diào)用patch類的正確方法。
首先厢呵,在編譯時Instant Run為每個類插入IncrementalChange變量
IncrementalChange $change;
為每一個方法添加類似如下代碼:
public void onCreate(Bundle savedInstanceState) {
IncrementalChange var2 = $change;
//$change不為null窝撵,表示該類有修改,需要重定向
if(var2 != null) {
//通過access$dispatch方法跳轉(zhuǎn)到patch類的正確方法
var2.access$dispatch("onCreate.(Landroid/os/Bundle;)V", new Object[]{this, savedInstanceState});
} else {
super.onCreate(savedInstanceState);
this.setContentView(2130968601);
this.tv = (TextView)this.findViewById(2131492944);
}
}
如上代碼襟铭,當(dāng)一個類被修改后碌奉,Instant Run會為這個類新建一個類,命名為xxx&override寒砖,且實現(xiàn)IncrementalChange接口赐劣,并且賦值給原類的$change變量。
public class MainActivity$override implements IncrementalChange {
}
此時哩都,在運行時原類中每個方法的var2 != null魁兼,通過accessdispatch(參數(shù)是方法名和原參數(shù))定位到patch類MainActivitydispatch(參數(shù)是方法名和原參數(shù))定位到patch類MainActivityoverride中修改后的方法。
Instant Run是google在AS2.0時用來實現(xiàn)“熱部署”的漠嵌,同時也為“熱修復(fù)”提供了一個絕佳的思路咐汞。美團的Robust就是基于此。
2.2:資源修復(fù)方案
這里我們來看看install run的原理即可儒鹿,市面上的常見修復(fù)方案大部分都是基于此方法化撕。
public static void monkeyPatchExistingResources(Context context,
String externalResourceFile, Collection<Activity> activities) {
if (externalResourceFile == null) {
return;
}
try {
// 創(chuàng)建一個新的AssetManager
AssetManager newAssetManager = (AssetManager) AssetManager.class
.getConstructor(new Class[0]).newInstance(new Object[0]); // ... 1
Method mAddAssetPath = AssetManager.class.getDeclaredMethod(
"addAssetPath", new Class[] { String.class }); // ... 2
mAddAssetPath.setAccessible(true);
// 通過反射調(diào)用addAssetPath方法加載外部的資源(SD卡資源)
if (((Integer) mAddAssetPath.invoke(newAssetManager,
new Object[] { externalResourceFile })).intValue() == 0) { // ... 3
throw new IllegalStateException(
"Could not create new AssetManager");
}
Method mEnsureStringBlocks = AssetManager.class.getDeclaredMethod(
"ensureStringBlocks", new Class[0]);
mEnsureStringBlocks.setAccessible(true);
mEnsureStringBlocks.invoke(newAssetManager, new Object[0]);
if (activities != null) {
for (Activity activity : activities) {
Resources resources = activity.getResources(); // ... 4
try {
// 反射得到Resources的AssetManager類型的mAssets字段
Field mAssets = Resources.class
.getDeclaredField("mAssets"); // ... 5
mAssets.setAccessible(true);
// 將mAssets字段的引用替換為新創(chuàng)建的newAssetManager
mAssets.set(resources, newAssetManager); // ... 6
} catch (Throwable ignore) {
...
}
// 得到Activity的Resources.Theme
Resources.Theme theme = activity.getTheme();
try {
try {
// 反射得到Resources.Theme的mAssets字段
Field ma = Resources.Theme.class
.getDeclaredField("mAssets");
ma.setAccessible(true);
// 將Resources.Theme的mAssets字段的引用替換為新創(chuàng)建的newAssetManager
ma.set(theme, newAssetManager); // ... 7
} catch (NoSuchFieldException ignore) {
...
}
...
} catch (Throwable e) {
Log.e("InstantRun",
"Failed to update existing theme for activity "
+ activity, e);
}
pruneResourceCaches(resources);
}
}
/**
* 根據(jù)SDK版本的不同,用不同的方式得到Resources 的弱引用集合
*/
Collection<WeakReference<Resources>> references;
if (Build.VERSION.SDK_INT >= 19) {
Class<?> resourcesManagerClass = Class
.forName("android.app.ResourcesManager");
Method mGetInstance = resourcesManagerClass.getDeclaredMethod(
"getInstance", new Class[0]);
mGetInstance.setAccessible(true);
Object resourcesManager = mGetInstance.invoke(null,
new Object[0]);
try {
Field fMActiveResources = resourcesManagerClass
.getDeclaredField("mActiveResources");
fMActiveResources.setAccessible(true);
ArrayMap<?, WeakReference<Resources>> arrayMap = (ArrayMap) fMActiveResources
.get(resourcesManager);
references = arrayMap.values();
} catch (NoSuchFieldException ignore) {
Field mResourceReferences = resourcesManagerClass
.getDeclaredField("mResourceReferences");
mResourceReferences.setAccessible(true);
references = (Collection) mResourceReferences
.get(resourcesManager);
}
} else {
Class<?> activityThread = Class
.forName("android.app.ActivityThread");
Field fMActiveResources = activityThread
.getDeclaredField("mActiveResources");
fMActiveResources.setAccessible(true);
Object thread = getActivityThread(context, activityThread);
HashMap<?, WeakReference<Resources>> map = (HashMap) fMActiveResources
.get(thread);
references = map.values();
}
//遍歷并得到弱引用集合中的 Resources 约炎,將 Resources mAssets 字段引用替換成新的 AssetManager
for (WeakReference<Resources> wr : references) {
Resources resources = (Resources) wr.get();
if (resources != null) {
try {
Field mAssets = Resources.class
.getDeclaredField("mAssets");
mAssets.setAccessible(true);
mAssets.set(resources, newAssetManager);
} catch (Throwable ignore) {
...
}
resources.updateConfiguration(resources.getConfiguration(),
resources.getDisplayMetrics());
}
}
} catch (Throwable e) {
throw new IllegalStateException(e);
}
}
- 在注釋1處創(chuàng)建一個新的 AssetManager 植阴,
- 在注釋2 和注釋3 處通過反射調(diào)用 addAssetPath 方法加載外部( SD 卡)的資源蟹瘾。
- 在注釋4 處遍歷 Activity 列表,得到每個 Activity 的 Resources 掠手,
- 在注釋5 處通過反射得到 Resources 的 AssetManager 類型的 rnAssets 字段 热芹,
- 注釋6處改寫 mAssets 字段的引用為新的 AssetManager 。
采用同樣的方式惨撇,
- 在注釋7處將 Resources. Theme 的 m Assets 字段 的引用替換為新創(chuàng)建的 AssetManager 伊脓。
- 緊接著 根據(jù) SDK 版本的不同,用不同的方式得到 Resources 的弱引用集合魁衙,
- 再遍歷這個弱引用集合报腔, 將弱引用集合中的 Resources 的 mAssets 字段引用都替換成新創(chuàng)建的 AssetManager 。
資源修復(fù)原理
:
- 1.創(chuàng)建新的AssetManager剖淀,通過反射調(diào)用addAssetPath方法纯蛾,加載外部資源,這樣新創(chuàng)建的AssetManager就含有了外部資源
- 2.將AssetManager類型的mAsset字段全部用新創(chuàng)建的AssetManager對象替換纵隔。這樣下次加載資源文件的時候就可以找到包含外部資源文件的AssetManager翻诉。
2.3:動態(tài)鏈接庫so的修復(fù)
1.接口調(diào)用替換方案:
sdk提供接口替換System默認加載so庫接口
SOPatchManager.loadLibrary(String libName) -> System.loadLibrary(String libName)
SOPatchManager.loadLibrary接口加載 so庫的時候優(yōu)先嘗試去加載sdk 指定目錄下的補丁so,
加載策略
如下:
如果存在則加載補丁 so庫而不會去加載安裝apk安裝目錄下的so庫 如果不存在補丁so捌刮,那么調(diào)用System.loadLibrary去加載安裝apk目錄下的 so庫碰煌。
我們可以很清楚的看到這個方案的優(yōu)缺點: 優(yōu)點:不需要對不同 sdk 版本進行兼容,因為所有的 sdk 版本都有 System.loadLibrary 這個接口绅作。 缺點:調(diào)用方需要替換掉 System 默認加載 so 庫接口為 sdk提供的接口芦圾, 如果是已經(jīng)編譯混淆好的三方庫的so 庫需要 patch,那么是很難做到接口的替換
雖然這種方案實現(xiàn)簡單俄认,同時不需要對不同 sdk版本區(qū)分處理个少,但是有一定的局限性沒法修復(fù)三方包的so庫同時需要強制侵入接入方接口調(diào)用,接著我們來看下反射注入方案眯杏。
2夜焦、反射注入方案
前面介紹過 System. loadLibrary ( "native-lib"); 加載 so庫的原理,其實native-lib 這個 so 庫最終傳給 native 方法執(zhí)行的參數(shù)是 so庫在磁盤中的完整路徑岂贩,比如:/data/app-lib/com.taobao.jni-2/libnative-lib.so, so庫會在 DexPathList.nativeLibraryDirectories/nativeLibraryPathElements 變量所表示的目錄下去遍歷搜索
sdk<23 DexPathList.findLibrary 實現(xiàn)如下
可以發(fā)現(xiàn)會遍歷 nativeLibraryDirectories數(shù)組茫经,如果找到了 loUtils.canOpenReadOnly (path)返回為 true, 那么就直接返回該 path, loUtils.canOpenReadOnly (path)返回為 true 的前提肯定是需要 path 表示的 so文件存 在的。那么我們可以采取類似類修復(fù)反射注入方式河闰,只要把我們的補丁so庫的路徑插入到nativeLibraryDirectories數(shù)組的最前面就能夠達到加載so庫的時候是補丁 庫而不是原來so庫的目錄科平,從而達到修復(fù)的目的。
sdk>=23 DexPathList.findLibrary 實現(xiàn)如下
sdk23 以上 findLibrary 實現(xiàn)已經(jīng)發(fā)生了變化姜性,如上所示,那么我們只需要把補丁so庫的完整路徑作為參數(shù)構(gòu)建一個Element對象髓考,然后再插入到nativeLibraryPathElements 數(shù)組的最前面就好了部念。
- 優(yōu)點:可以修復(fù)三方庫的so庫。同時接入方不需要像方案1 —樣強制侵入用 戶接口調(diào)用
- 缺點:需要不斷的對 sdk 進行適配,如上 sdk23 為分界線儡炼,findLibrary接口實現(xiàn)已經(jīng)發(fā)生了變化妓湘。
對于 so庫的修復(fù)方案目前更多采取的是接口調(diào)用替換方式,需要強制侵入用戶 接口調(diào)用乌询。 目前我們的so文件修復(fù)方案采取的是反射注入的方案榜贴,重啟生效。具有更好的普遍性妹田。 如果有so文件修復(fù)實時生效的需求唬党,也是可以做到的,只是有些限制情況鬼佣。
常見熱修復(fù)框架驶拱?
特性 | Dexposed | AndFix | Tinker/Amigo | QQ Zone | Robust/Aceso | Sophix |
---|---|---|---|---|---|---|
技術(shù)原理 | native底層替換 | native底層替換 | 類加載 | 類加載 | Instant Run | 混合 |
所屬 | 阿里 | 阿里 | 微信/餓了么 | QQ空間 | 美團/蘑菇街 | 阿里 |
即時生效 | YES | YES | NO | NO | YES | 混合 |
方法替換 | YES | YES | YES | YES | YES | YES |
類替換 | NO | NO | YES | YES | YES | YES |
類結(jié)構(gòu)修改 | NO | NO | YES | NO | NO | YES |
資源替換 | NO | NO | YES | YES | NO | YES |
so替換 | NO | NO | YES | NO | NO | YES |
支持gradle | NO | NO | YES | YES | YES | YES |
支持ART | NO | YES | YES | YES | YES | YES |
可以看出,阿里系多采用native底層方案晶衷,騰訊系多采用類加載機制蓝纲。其中,Sophix是商業(yè)化方案晌纫;Tinker/Amigo支持特性較多税迷,同時也更復(fù)雜,如果需要修復(fù)資源和so锹漱,可以選擇翁狐;如果僅需要方法替換,且需要即時生效凌蔬,Robust是不錯的選擇露懒。
總結(jié):
盡管熱修復(fù)(或熱更新)相對于迭代更新有諸多優(yōu)勢,市面上也有很多開源方案可供選擇砂心,但目前熱修復(fù)依然無法替代迭代更新模式懈词。有如下原因: 熱修復(fù)框架多多少少會增加性能開銷,或增加APK大小 熱修復(fù)技術(shù)本身存在局限辩诞,比如有些方案無法替換so或資源文件 熱修復(fù)方案的兼容性坎弯,有些方案無法同時兼顧Dalvik和ART,有些深度定制系統(tǒng)也無法正常工作 監(jiān)管風(fēng)險译暂,比如蘋果系統(tǒng)嚴格限制熱修復(fù)
所以抠忘,對于功能迭代和常規(guī)bug修復(fù),版本迭代更新依然是主流外永。一般的代碼修復(fù)崎脉,使用Robust可以解決,如果還需要修復(fù)資源或so庫伯顶,可以考慮Tinker囚灼。
參考文章
作者:高級攻城獅
鏈接:https://juejin.cn/post/7142481619604111390