前言

热修复到现在2022年已经不是一个新名词,但是作为Android开发核心技术栈的一部分,我这里还得来一次冷饭热炒。

随着移动端业务复杂程度的增加,传统的版本更新流程显然无法满足业务和开发者的需求, 热修复技术的推出在很大程度上改善了这一局面。国内大部分成熟的主流 App都拥有自己的热更新技术,像手淘、支付宝、微信、QQ、饿了么、美团等。

可以说,一个好的热修复技术,将为你的 App助力百倍。对于每一个想在 Android 开发领域有所造诣的开发者,掌握热修复技术更是必备的素质

热修复是 Android 大厂面试中高频面试知识点,也是我们必须要掌握的知识点。热修复技术,可以看作 Android平台发展成熟至一定阶段的必然产物。 Android热修复了解吗?修复哪些东西? 常见热修复框架对比以及各原理分析?

1.什么是热修复

热修复说白了就是不再使用传统的应用商店更新或者自更新方式,使用补丁包推送的方式在用户无感知的情况下,修复应用bug或者推送新的需求

传统更新热更新过程对比如下:

热修复优缺点:

  • 优点:

    • 1.只需要打补丁包,不需要重新发版本。
    • 2.用户无感知,不需要重新下载最新应用
    • 3.修复成功率高
  • 缺点
    • 补丁包滥用,容易导致应用版本不可控,需要开发一套完整的补丁包更新机制,会增加一定的成本

2.热修复方案

首先我们得知道热修复修复哪些东西

  • 1.代码修复
  • 2.资源修复
  • 3.动态库修复

2.1:代码修复方案

从技术角度来说,我们的目的是非常明确的:把错误的代码替换成正确的代码。 注意这里的替换,并不是直接擦写dx文件,而是提供一份新的正确代码,让应用运行时绕过错误代码,执行新的正确代码。

想法简单直接,但实现起来并不容易。目前主要有三类技术方案:

2.1.1.类加载方案

之前分析类加载机制有说过: 加载流程先是遵循双亲委派原则,如果委派原则没有找到此前加载过此类, 则会调用CLassLoader的findClass方法,再去BaseDexClassLoader下面的dexElements数组中查找,如果没有找到,最终调用defineClassNative方法加载

代码修复就是基于这点: 将新的做了修复的dex文件,通过反射注入到BaseDexClassLoader的dexElements数组的第一个位置上dexElements[0],下次重新启动应用加载类的时候,会优先加载做了修复的dex文件,这样就达到了修复代码的目的。原理很简单

代码如下:

public class Hotfix {public static void patch(Context context, String patchDexFile, String patchClassName)throws ClassNotFoundException, NoSuchFieldException, IllegalAccessException, InstantiationException, NoSuchMethodException, InvocationTargetException {//获取系统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重新设置回pathClassLoadersetDexElements(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);//设置为可访问Object pathList = pathListField.get(classLoader);//然后获取“pathList”实例的“dexElements”属性Field dexElementField = pathList.getClass().getDeclaredField("dexElements");dexElementField.setAccessible(true);//读取"dexElements"的值Object elements = dexElementField.get(pathList);return elements;}//合拼dexElementsprivate 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);//创建一个新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);//设置为可访问Object pathList = pathListField.get(classLoader);//然后获取“pathList”实例的“dexElements”属性Field declaredField = pathList.getClass().getDeclaredField("dexElements");declaredField.setAccessible(true);//设置"dexElements"的值declaredField.set(pathList, dexElements);}
}

类加载过程如下:

微信Tinker,QQ 空间的超级补丁、手 QQ 的QFix 、饿了 么的 AmigoNuwa 等都是使用这个方式

缺点:因为类加载后无法卸载,所以类加载方案必须重启App,让bug类重新加载后才能生效。

2.1.2:底层替换方案

底层替换方案不会再次加载新类,而是直接在 Native 层 修改原有类, 这里我们需要提到Art虚拟机中ArtMethod: 每一个Java方法在Art虚拟机中都对应着一个 ArtMethodArtMethod记录了这个Java方法的所有信息,包括所属类、访问权限、代码执行地址等

结构如下:

// 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_;      // 1void* entry_point_from_jni_;void* entry_point_from_quick_compiled_code_;  //2} ptr_sized_fields_;...
}

在 ArtMethod结构体中,最重要的就是 注释1和注释2标注的内容,从名字可以看出来,他们就是方法的执行入口。 我们知道,Java代码在Android中会被编译为 Dex Code

Art虚拟机中可以采用解释模式或者 AOT机器码模式执行 Dex Code

  • 解释模式: 就是去除Dex Code,逐条解释执行。 如果方法的调用者是以解释模式运行的,在调用这个方法时,就会获取这个方法的 entry_point_from_interpreter_,然后跳转执行。
  • AOT模式: 就会预先编译好 Dex Code对应的机器码,然后在运行期直接执行机器码,不需要逐条解释执行Dex Code。 如果方法的调用者是以AOT机器码方式执行的,在调用这个方法时,就是跳转到 entry_point_from_quick_compiled_code_中执行。

那是不是只需要替换这个几个 entry_point_* 入口地址就能够实现方法替换了呢? 并没有那么简单,因为不论是解释模式还是AOT模式,在运行期间还会需要调用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);  // 1art::mirror::ArtMethod* dmeth =(art::mirror::ArtMethod*) env->FromReflectedMethod(dest);  // 2...// 3smeth->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结构体是Android开源的一部分,所以每个手机厂商都可能会去更改这部分的内容,这就可能导致ArtMethod替换方案在某些机型上面出现未知错误。

Sophix为了规避上面的AndFix的风险,采用直接替换整个结构体。这样不管手机厂商如何更改系统,我们都可以正确定位到方法地址

2.4.3:install run方案

Instant Run 方案的核心思想是——插桩在编译时通过插桩在每一个方法中插入代码,修改代码逻辑,在需要时绕过错误方法,调用patch类的正确方法。

首先,在编译时Instant Run为每个类插入IncrementalChange变量

IncrementalChange  $change;

为每一个方法添加类似如下代码:

public void onCreate(Bundle savedInstanceState) {IncrementalChange var2 = $change;//$change不为null,表示该类有修改,需要重定向if(var2 != null) {//通过access$dispatch方法跳转到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);}
}

如上代码,当一个类被修改后,Instant Run会为这个类新建一个类,命名为xxx&override,且实现IncrementalChange接口,并且赋值给原类的$change变量。

public class MainActivity$override implements IncrementalChange {
}

此时,在运行时原类中每个方法的var2 != null,通过accessdispatch(参数是方法名和原参数)定位到patch类MainActivitydispatch(参数是方法名和原参数)定位到patch类MainActivityoverride中修改后的方法。

Instant Run是google在AS2.0时用来实现“热部署”的,同时也为“热修复”提供了一个绝佳的思路。美团的Robust就是基于此

2.2:资源修复方案

这里我们来看看install run的原理即可,市面上的常见修复方案大部分都是基于此方法。

public static void monkeyPatchExistingResources(Context context,String externalResourceFile, Collection<Activity> activities) {if (externalResourceFile == null) {return;}try {
// 创建一个新的AssetManagerAssetManager newAssetManager = (AssetManager) AssetManager.class.getConstructor(new Class[0]).newInstance(new Object[0]); // ... 1Method mAddAssetPath = AssetManager.class.getDeclaredMethod("addAssetPath", new Class[] { String.class }); // ... 2mAddAssetPath.setAccessible(true);
// 通过反射调用addAssetPath方法加载外部的资源(SD卡资源)if (((Integer) mAddAssetPath.invoke(newAssetManager,new Object[] { externalResourceFile })).intValue() == 0) { // ... 3throw 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(); // ... 4try {
// 反射得到Resources的AssetManager类型的mAssets字段Field mAssets = Resources.class.getDeclaredField("mAssets"); // ... 5mAssets.setAccessible(true);
// 将mAssets字段的引用替换为新创建的newAssetManagermAssets.set(resources, newAssetManager); // ... 6} catch (Throwable ignore) {...}// 得到Activity的Resources.ThemeResources.Theme theme = activity.getTheme();try {try {
// 反射得到Resources.Theme的mAssets字段Field ma = Resources.Theme.class.getDeclaredField("mAssets");ma.setAccessible(true);
// 将Resources.Theme的mAssets字段的引用替换为新创建的newAssetManagerma.set(theme, newAssetManager); // ... 7} catch (NoSuchFieldException ignore) {...}...} catch (Throwable e) {Log.e("InstantRun","Failed to update existing theme for activity "+ activity, e);}pruneResourceCaches(resources);}}
/**
*  根据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 字段引用替换成新的 AssetManagerfor (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处创建一个新的 AssetManager ,
  • 注释2注释3 处通过反射调用 addAssetPath 方法加载外部( SD 卡)的资源。
  • 注释4 处遍历 Activity 列表,得到每个 Activity 的 Resources ,
  • 注释5 处通过反射得到 Resources 的 AssetManager 类型的 rnAssets 字段 ,
  • 注释6处改写 mAssets 字段的引用为新的 AssetManager 。

采用同样的方式

  • 注释7处将 Resources. Theme 的 m Assets 字段 的引用替换为新创建的 AssetManager 。
  • 紧接着 根据 SDK 版本的不同,用不同的方式得到 Resources 的弱引用集合,
  • 再遍历这个弱引用集合, 将弱引用集合中的 Resources 的 mAssets 字段引用都替换成新创建的 AssetManager 。

资源修复原理

  • 1.创建新的AssetManager,通过反射调用addAssetPath方法,加载外部资源,这样新创建的AssetManager就含有了外部资源
  • 2.将AssetManager类型的mAsset字段全部用新创建的AssetManager对象替换。这样下次加载资源文件的时候就可以找到包含外部资源文件的AssetManager。

2.3:动态链接库so的修复

1.接口调用替换方案:

sdk提供接口替换System默认加载so库接口

SOPatchManager.loadLibrary(String libName) -> System.loadLibrary(String libName)

SOPatchManager.loadLibrary接口加载 so库的时候优先尝试去加载sdk 指定目录下的补丁so

加载策略如下:

如果存在则加载补丁 so库而不会去加载安装apk安装目录下的so库 如果不存在补丁so,那么调用System.loadLibrary去加载安装apk目录下的 so库。

我们可以很清楚的看到这个方案的优缺点: 优点:不需要对不同 sdk 版本进行兼容,因为所有的 sdk 版本都有 System.loadLibrary 这个接口。 缺点:调用方需要替换掉 System 默认加载 so 库接口为 sdk提供的接口, 如果是已经编译混淆好的三方库的so 库需要 patch,那么是很难做到接口的替换

虽然这种方案实现简单,同时不需要对不同 sdk版本区分处理,但是有一定的局限性没法修复三方包的so库同时需要强制侵入接入方接口调用,接着我们来看下反射注入方案。

2、反射注入方案

前面介绍过 System. loadLibrary ( “native-lib”); 加载 so库的原理,其实native-lib 这个 so 库最终传给 native 方法执行的参数是 so库在磁盘中的完整路径,比如:/data/app-lib/com.taobao.jni-2/libnative-lib.so, so库会在 DexPathList.nativeLibraryDirectories/nativeLibraryPathElements 变量所表示的目录下去遍历搜索

sdk<23 DexPathList.findLibrary 实现如下

可以发现会遍历 nativeLibraryDirectories数组,如果找到了 loUtils.canOpenReadOnly (path)返回为 true, 那么就直接返回该 path, loUtils.canOpenReadOnly (path)返回为 true 的前提肯定是需要 path 表示的 so文件存 在的。那么我们可以采取类似类修复反射注入方式,只要把我们的补丁so库的路径插入到nativeLibraryDirectories数组的最前面就能够达到加载so库的时候是补丁 库而不是原来so库的目录,从而达到修复的目的。

sdk>=23 DexPathList.findLibrary 实现如下

sdk23 以上 findLibrary 实现已经发生了变化,如上所示,那么我们只需要把补丁so库的完整路径作为参数构建一个Element对象,然后再插入到nativeLibraryPathElements 数组的最前面就好了。

  • 优点:可以修复三方库的so库。同时接入方不需要像方案1 —样强制侵入用 户接口调用
  • 缺点:需要不断的对 sdk 进行适配,如上 sdk23 为分界线,findLibrary接口实现已经发生了变化。

对于 so库的修复方案目前更多采取的是接口调用替换方式,需要强制侵入用户 接口调用。 目前我们的so文件修复方案采取的是反射注入的方案,重启生效。具有更好的普遍性。 如果有so文件修复实时生效的需求,也是可以做到的,只是有些限制情况。

常见热修复框架?

可以看出,阿里系多采用native底层方案,腾讯系多采用类加载机制。其中,Sophix是商业化方案;Tinker/Amigo支持特性较多,同时也更复杂,如果需要修复资源和so,可以选择;如果仅需要方法替换,且需要即时生效,Robust是不错的选择。

总结:

尽管热修复(或热更新)相对于迭代更新有诸多优势,市面上也有很多开源方案可供选择,但目前热修复依然无法替代迭代更新模式。有如下原因: 热修复框架多多少少会增加性能开销,或增加APK大小 热修复技术本身存在局限,比如有些方案无法替换so或资源文件 热修复方案的兼容性,有些方案无法同时兼顾Dalvik和ART,有些深度定制系统也无法正常工作 监管风险,比如苹果系统严格限制热修复

所以,对于功能迭代和常规bug修复,版本迭代更新依然是主流。一般的代码修复,使用Robust可以解决,如果还需要修复资源或so库,可以考虑Tinker


最后分享一份我自己收录整理的Android学习PDF+架构视频+面试文档+核心笔记,高级架构技术进阶脑图、Android开发面试专题文档,高级进阶架构文档。这些都是我现在闲暇还会反复翻阅的精品学习文档。里面对近几年的大厂面试高频问题都有详细的讲解,也是对我这次面试通过有很大的帮助。相信可以有效的帮助大家掌握知识、理解原理。

当然你也可以拿去查漏补缺,提升自身的竞争力。↓↓↓

有需要的可以复制下方链接,传送直达!!!
https://qr21.cn/CaZQLo?BIZ=ECOMMERCE

一个好的热修复技术,将为你的 App助力百倍相关推荐

  1. 性能优化——Android热修复技术,类加载机制详解

    一.背景 热修复技术慢慢的成为Android开发必不可少的技术,也是成为一名高级程序员必不可少的技能之一.那么什么是热修复技术呢? 当app上线之后,发现了一个严重的bug,需要紧急修复,按照以往的惯 ...

  2. 深入解析阿里Android热修复技术原理

    前言:本文框架 什么是热修复? 热修复框架分类 技术原理及特点 Tinker框架解析 各框架对比图 总结 通过阅读本文,你会对热修复技术有更深的认知,本文会列出各类框架的优缺点以及技术原理,文章末尾简 ...

  3. Android热修复技术原理详解(最新最全版本)

    本文框架 什么是热修复? 热修复框架分类 技术原理及特点 Tinker框架解析 各框架对比图 总结   通过阅读本文,你会对热修复技术有更深的认知,本文会列出各类框架的优缺点以及技术原理,文章末尾简单 ...

  4. [读书笔记] 深入探索Android热修复技术原理 (手淘技术团队)

    热修复技术介绍 探索之路 最开始,手淘是基于Xposed进行了改进,产生了针对Android Dalvik虚拟机运行时的Java Method Hook技术--Dexposed. 但该方案对于底层Da ...

  5. Android热修复技术初探(三):动态加载外部资源

    前面已经介绍了Android平台上的几种ClassLoader,这几种ClassLoader都有各自的使用场景,有了这些基础知识之后,才能更好地理解以及探究Android热修复技术.首先我们来探究怎么 ...

  6. android热修复技术tinker,Android热修复方案第一弹——Tinker篇

    背景 一款App的正常开发流程应该是这样的:新版本上线-->用户安装-->发现Bug-->紧急修复-->重新发布新版本-->提示用户安装更新,从表面上看这样的开发流程顺理 ...

  7. 热修复原理学习(1)热修复技术介绍

    今天开始学习热修复的原理知识,学习方向是阿里团队编写的<深入探索Android热修复技术原理>,所以研究的热修复框架是Sophix. 之前对热修复的知识做过了解,具体是这一篇:热修复原理学 ...

  8. 热修复系列——Android热修复技术进阶篇

    目录 1 前言 2 热修复技术 2.1 Dexposed 2.2 AndFix 2.3 QZone 2.4 Tinker 2.6 epic 2.7 YAHFA 2.8 FastHook 2.9 美团R ...

  9. Android热修复技术选型参考

    背景 热修复就是通过下发补丁包,让已安装的客户端动态更新,用户不用重新安装APP,就能够修复软件缺陷. 热修复技术对比 1.公司角度 大致可以分为阿里系和腾讯系和其他,如下: 阿里系 Dexposed ...

  10. Android热修复技术总结

    插件化和热修复技术是Android开发中比较高级的知识点,是中级开发人员通向高级开发中必须掌握的技能,插件化的知识可以查我我之前的介绍:Android插件化.本篇重点讲解热修复,并对当前流行的热修复技 ...

最新文章

  1. sed当中使用变量替换以及执行外部命令
  2. 香蕉派 banana pi github 最新Linux 代码升级到kernel 4.2.
  3. Swagger2异常:Illegal DefaultValue null for parameter type integer java
  4. 生产环境中Oracle常用函数总结
  5. 四宫格效果 css_【深度教研】智力游戏“九宫格” 集体教研活动纪实
  6. ThinkPHP6项目基操(14.实战部分 中间件处理登录流程)
  7. ext中的EXT.XTemplate()
  8. 上一家单位离职的原因_员工离职再入职,专项附加扣除该如何变更?
  9. fcpx调整图层_【FCPX萌新系列】新手常遇到的4个基础调色问题
  10. BOM对象有哪些,列举window对象?
  11. 改变你一生的五句话 (转)
  12. 湘西州2021年高考成绩查询,2021年湘西高考状元名单公布,湘西文理科状元是谁多少分...
  13. 离线 维基百科 android,维基百科离线版(Kiwix)
  14. 如何获取HanLP的nature中文名
  15. 网络控制播放器(局域网内通过TCP和UDP控制视频播放器)
  16. UML建模之用例图(重点讲extend和include关系)
  17. 今天是2019年最后一天,全球金融危机,离我们已经过去十年了
  18. vs2012 visual studio 2012安装失败管道正在关闭解决方法
  19. 百万级用户量的站内信设计
  20. 方舟 linux服务器设置,方舟生存进化私人服务器怎么设置 方舟手游私服设置教程...

热门文章

  1. vscode 历史版本1.68.1
  2. Power Query 系列 (05) - M 语言介绍
  3. 【经典算法题】排列序列
  4. CAN通信(2)——CAN通信协议层
  5. linux解决蓝牙和网卡,完全用Deepin Linux娱乐、工作、学习(3)-- 无线网卡及蓝牙设备驱动篇...
  6. 软件测试典型缺陷分析,软件测试缺陷分析方法简介
  7. ahk键盘增强✨✨✨v1.1
  8. 移动边缘计算MEC学习笔记
  9. 不要老盯着存储,存储的价值在于数据流:Filenet
  10. 视频ToneMapping(HDR转SDR)中的颜色空间转换问题(BT2020转BT709,YCbCr、YUV和RGB)