开发者社区> code_xzh> 正文
阿里云
为了无法计算的价值
打开APP
阿里云APP内打开

Android动态加载入坑指南

简介: 曾几何时,国内各大公司掀起了一股研究Android动态加载的技术,两年多过去了,动态加载技术俨然成了Android开发中必须掌握的技术。那么动态加载技术是什么呢,这里谈谈我的个人看法,如有雷同,纯属偶然。 什么是动态加载技术 对于动态加载的概念,没有一个权威的定义,参考网上的解释,我们举一个例子,动态加载代码就是通过在运行时加载外部代码(磁盘,网络等)改变程序行为的技术(感觉有点像装饰者模式)
+关注继续查看

曾几何时,国内各大公司掀起了一股研究Android动态加载的技术,两年多过去了,动态加载技术俨然成了Android开发中必须掌握的技术。那么动态加载技术是什么呢,这里谈谈我的个人看法,如有雷同,纯属偶然。

什么是动态加载技术

对于动态加载的概念,没有一个权威的定义,参考网上的解释,我们举一个例子,动态加载代码就是通过在运行时加载外部代码(磁盘,网络等)改变程序行为的技术(感觉有点像装饰者模式)。主要目的是为了达到让用户不用重新安装APK就能升级应用的功能。
为了加深大家对这种概念的理解,我们结合pc端来说说手机端的动态加载。

传统的pc端动态加载
熟悉Java的同学应该比较清楚,Java的可执行文件是Jar,运行在虚拟机上JVM上,虚拟机通过ClassLoader加载Jar文件并执行里面的代码。所以Java程序也可以通过动态调用Jar文件达到动态加载的目的。
动态加载技术在PC软件领域广泛使用,比如qq上线的时候忘了某个功能的修复,这个时候就可以用动态加载来修复我们的bug。

Android应用的动态加载技术
Android应用类似于Java程序,只不过虚拟机换成了Dalvik/ART,而Jar换成了Dex。我们知道,在Android的apk文件中往往有一个或者多个Dex文件,系统的类加载器(PathDexClassLoader)加载的就是dex文件,虽然一个apk一旦构建出来,我们是无法更换里面的Dex文件的,但是我们可以在类加载动态加载外部的dex文件来达到动态加载的目的。

JVM 类加载机制

JVM 的类加载机制是双亲委派模型,这里贴上JVM加载的图解。

对于上面这张图,我们有以下几点需要说明。
  • BootStrapClassLoader是顶级的类加载器,它是唯一一个不继承自ClassLoader中的类加载器,它高度集成于 JVM是ExtensionClassLoader的父加载器,它的类加载路径是JDK\jre\lib 和 用户指定的虚拟机参数-Xbootclasspath的值。
  • ExtensionClassLoader 是 BootStrapClassLoader 的子加载器,同时是 SystemClassLoader(有的地方称 AppClassLoader)的父加载器,它的类加载路径是 JDK\jre\lib\ext 和系统属性 java.ext.dirs 的值。
  • SystemClassLoader 是 ExtensionClassLoader 的子加载器,同时是我们的应用程序的类加载器,我们在应用程序中编写的类一般情况下(如果没有到动态加载技术的话)都是通过这个类加载加载的。它的类加载路径是环境变量 CLASSPATH 的值或者用户通过命令行可选项 -cp (-classpath) 指定的值。
  • 类加载器由于父子关系形成树形结构,开发人员可以开发自己的类加载器从而实现动态加载功能,但必须给这个类加载器指定树上的一个节点作为它的父加载器。
  • 因为类加载器是通过包名和类名(或者说类的全限定名),所以由于委派式加载机制的存在,全限定名相同的类不会在有 祖先—子孙 关系的类加载器上分别加载一次,不管这两个类的实现是否一样。
  • 不同的类加载器加载的类一定是不同的类,即使它们的全限定名一样。如果全限定名一样,那么根据上一条,这两个类加载器一定没有 祖先-子孙 的关系。这样来看,可以通过自定义类加载器使得相同全限定名但实现不同的类存在于同一 JVM 中,也就是说,类加载器相当于给类在包名之上又加了个命名空间。
  • 如果两个相同全限定名的类由两个非 祖先-子孙 关系的类加载器加载,这两个类之间通过instanceof 和 equals() 等进行比较时总是返回false
安卓应用和普通的 java 应用不同,它们运行于 Dalvik 虚拟机。JVM 是基于栈的虚拟机,而 Dalvik 是基于寄存器的虚拟机。Android采用 dex 作为储存类字节码信息的文件。当 java 程序编译成 class 后,编译器会使用 dx 工具将所有的class 文件整合到一个 dex 文件,目的是使其中各个类能够共享数据,在一定程度上降低了冗余,同时也是文件结构更加紧凑。
为了说明Android的类加载机制,我们需要对Android的ClassLoader做一个了解。
安卓中两个重要的类加载器:DexClassLoader 和 PathClassLoader。

那么对于Android来说,我们来看看Android的加载模型。

DexClassLoader & PathClassLoader说明

我们首先看一些这两个类。

package dalvik.system;
import java.io.File;

public class DexClassLoader extends BaseDexClassLoader {
   
    public DexClassLoader(String dexPath, String optimizedDirectory, String libraryPath, ClassLoader parent) {
        super(dexPath, new File(optimizedDirectory), libraryPath, parent);
    }
}
package dalvik.system;
public class PathClassLoader extends BaseDexClassLoader {
   
    public PathClassLoader(String dexPath, ClassLoader parent) {
        super(dexPath, null, null, parent);
    }
    public PathClassLoader(String dexPath, String libraryPath, ClassLoader parent) {
        super(dexPath, null, libraryPath, parent);
    }
}

可以看到,这两个类加载器都是继承自 BaseDexClassLoader,只是分别实现了自己的构造方法。
public BaseDexClassLoader(String dexPath, File optimizedDirectory, String librarySearchPath, ClassLoader parent) {
        super(parent);
        this.pathList = new DexPathList(this, dexPath, librarySearchPath, optimizedDirectory);
}

我们发现BaseDexClassLoader作为一个基类,其构造极其简单,它做了两件事:连接了父加载器;构造了一个 DexPathList 实例保存在 pathList 中。
参数意思如下:
  • 第一个参数指的是我们要加载的 dex 文件的路径,它有可能是多个 dex 路径,取决于我们要加载的 dex 文件的个数,多个路径之间用 : 隔开。
  • 第二个参数指的是优化后的 dex 存放目录。实际上,dex 其实还并不能被虚拟机直接加载,它需要系统的优化工具优化后才能真正被利用。优化之后的 dex 文件我们把它叫做 odex (optimized dex,说明这是被优化后的 dex)文件。其实从 class 到 dex 也算是经历了一次优化,这种优化的是机器无关的优化,也就是说不管将来运行在什么机器上,这种优化都是遵循固定模式的,因此这种优化发生在 apk 编译。而从 dex 文件到 odex 文件,是机器相关的优化,它使得 odex 适配于特定的硬件环境,不同机器这一步的优化可能有所不同,所以这一步需要在应用安装等运行时期由机器来完成。需要注意的是,在较早版本的系统中,这个目录可以指定为外部存储中的目录,较新版本的系统为了安全只允许其为应用程序私有存储空间(/data/data/apk-package-name/)下的目录,一般我们可以通过 Context#getDir(String dirName) 得到这个目录。
  • 第三个参数的意义是库文件的的搜索路径,一般来说是 .so 库文件的路径,也可以指明多个路径。
  • 第四个参数就是要传入的父加载器,一般情况我们可以通过 Context#getClassLoader() 得到应用程序的类加载器然后把它传进去。
好了,到这里就很清楚了,Dalvik 虚拟机要加载的 dex 文件的路径(DexPathList),那么Dalvik是如何找到Dex的呢?有人会说反射,对,大方向对了。那么我们看看系统究竟是怎么做的。
  DexPathList
public DexPathList(ClassLoader definingContext, String dexPath,
            String librarySearchPath, File optimizedDirectory) {

        if (definingContext == null) {
            throw new NullPointerException("definingContext == null");
        }

        if (dexPath == null) {
            throw new NullPointerException("dexPath == null");
       }

       if (optimizedDirectory != null) {
           if (!optimizedDirectory.exists())  {
               throw new IllegalArgumentException(
                       "optimizedDirectory doesn't exist: "
                       + optimizedDirectory);
           }

           if (!(optimizedDirectory.canRead()
                           && optimizedDirectory.canWrite())) {
               throw new IllegalArgumentException(
                       "optimizedDirectory not readable/writable: "
                       + optimizedDirectory);
           }
       }

       this.definingContext = definingContext;

       ArrayList<IOException> suppressedExceptions = new ArrayList<IOException>();
       // save dexPath for BaseDexClassLoader
       this.dexElements = makeDexElements(splitDexPath(dexPath), optimizedDirectory,
                              suppressedExceptions, definingContext);

       // Native libraries may exist in both the system and
       // application library paths, and we use this search order:
       //
       //   1. This class loader's library path for application libraries (librarySearchPath):
       //   1.1. Native library directories
       //   1.2. Path to libraries in apk-files
       //   2. The VM's library path from the system property for system libraries
       //      also known as java.library.path
       //
       // This order was reversed prior to Gingerbread; see http://b/2933456.
       this.nativeLibraryDirectories = splitPaths(librarySearchPath, false);
       this.systemNativeLibraryDirectories =
               splitPaths(System.getProperty("java.library.path"), true);
       List<File> allNativeLibraryDirectories = new ArrayList<>(nativeLibraryDirectories);
       allNativeLibraryDirectories.addAll(systemNativeLibraryDirectories);

       this.nativeLibraryPathElements = makePathElements(allNativeLibraryDirectories,
                                                         suppressedExceptions,
                                                         definingContext);

       if (suppressedExceptions.size() > 0) {
           this.dexElementsSuppressedExceptions =
               suppressedExceptions.toArray(new IOException[suppressedExceptions.size()]);
       } else {
           dexElementsSuppressedExceptions = null;
       }
}
这里我们主要看如下几行代码:
his.definingContext = definingContext;

       ArrayList<IOException> suppressedExceptions = new ArrayList<IOException>();
       // save dexPath for BaseDexClassLoader
       this.dexElements = makeDexElements(splitDexPath(dexPath), optimizedDirectory,
                              suppressedExceptions, definingContext);

       // Native libraries may exist in both the system and
       // application library paths, and we use this search order:
       //
       //   1. This class loader's library path for application libraries (librarySearchPath):
       //   1.1. Native library directories
       //   1.2. Path to libraries in apk-files
       //   2. The VM's library path from the system property for system libraries
       //      also known as java.library.path
       //
       // This order was reversed prior to Gingerbread; see http://b/2933456.
       this.nativeLibraryDirectories = splitPaths(librarySearchPath, false);
       this.systemNativeLibraryDirectories =
               splitPaths(System.getProperty("java.library.path"), true);
       List<File> allNativeLibraryDirectories = new ArrayList<>(nativeLibraryDirectories);
       allNativeLibraryDirectories.addAll(systemNativeLibraryDirectories);

       this.nativeLibraryPathElements = makePathElements(allNativeLibraryDirectories,
                                                         suppressedExceptions,
                                                         definingContext);

这段代码主要是给 dexElements和nativeLibraryPathElements赋值。我们知道Android在通过默认的虚拟机dex后,会继续优化为odex 文件。
dexElements 是通过 makeDexElements() 方法得到的。makeDexElements的方法里面我们主要关注前面两个参数,我们来看一下splitDexPath(dexPath)。
private static List<File> splitPaths(String searchPath, boolean directoriesOnly) {
       List<File> result = new ArrayList<>();

       if (searchPath != null) {
           for (String path : searchPath.split(File.pathSeparator)) {
               if (directoriesOnly) {
                   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;
}

这个方法很简单就是用,分隔的路径分割后保存为 File 类型的列表返回。现在看看 makeDexElements() 这个方法:
private static Element[] makeDexElements(List<File> files, File optimizedDirectory,
                                            List<IOException> suppressedExceptions,
                                            ClassLoader loader) {
     return makeElements(files, optimizedDirectory, suppressedExceptions, false, loader);
}
private static Element[] makeElements(List<File> files, File optimizedDirectory,
                                          List<IOException> suppressedExceptions,
                                          boolean ignoreDexFiles,
                                         ClassLoader loader) {
       Element[] elements = new Element[files.size()];
       int elementsPos = 0;
       /*
        * Open all files and load the (direct or contained) dex files
        * up front.
        */
       for (File file : files) {
           File zip = null;
           File dir = new File("");
           DexFile dex = null;
           String path = file.getPath();
           String name = file.getName();

           if (path.contains(zipSeparator)) {
               String split[] = path.split(zipSeparator, 2);
               zip = new File(split[0]);
               dir = new File(split[1]);
           } else if (file.isDirectory()) {
               // We support directories for looking up resources and native libraries.
               // Looking up resources in directories is useful for running libcore tests.
               elements[elementsPos++] = new Element(file, true, null, null);
           } else if (file.isFile()) {
               if (!ignoreDexFiles && name.endsWith(DEX_SUFFIX)) {
                   // Raw dex file (not inside a zip/jar).
                   try {
                       dex = loadDexFile(file, optimizedDirectory, loader, elements);
                   } catch (IOException suppressed) {
                       System.logE("Unable to load dex file: " + file, suppressed);
                       suppressedExceptions.add(suppressed);
                   }
               } else {
                   zip = file;

                   if (!ignoreDexFiles) {
                       try {
                           dex = loadDexFile(file, optimizedDirectory, loader, elements);
                       } catch (IOException suppressed) {
                           /*
                            * IOException might get thrown "legitimately" by the DexFile constructor if
                            * the zip file turns out to be resource-only (that is, no classes.dex file
                            * in it).
                            * Let dex == null and hang on to the exception to add to the tea-leaves for
                            * when findClass returns null.
                            */
                           suppressedExceptions.add(suppressed);
                       }
                   }
               }
           } else {
               System.logW("ClassLoader referenced unknown path: " + file);
           }

           if ((zip != null) || (dex != null)) {
               elements[elementsPos++] = new Element(dir, false, zip, dex);
           }
        }
       if (elementsPos != elements.length) {
           elements = Arrays.copyOf(elements, elementsPos);
       }
       return elements;
}

通过代码我们可以大致了解到,这个方法就是将之前的File对象通过重新组合成一个新的Elements对象,然后我们Loader读取的就是Element对象。看一下 loadDexFile() 怎样加载 DexFile 的
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);
       }
}

先说明下无论是 DexFile(File file, Classloader loader, Elements[] elements) 还是 DexFile.loadDex() 最终都会调用 DexFile(String sourceName, String outputName, int flags, ClassLoader loader, DexPathList.Element[] elements) 这个构造方法。所以这个方法的逻辑就是:如果 optimizedDirectory 为 null,那么就直接利用 file 的路径构造一个 DexFile;否则就根据要加载的 dex(或者包含了 dex 的 zip) 的文件名和优化后的 dex 存放的目录组合成优化后的 dex(也就是 odex)文件的输出路径,然后利用原始路径和优化后的输出路径构造出一个DexFile.
分析完这两字段,现在我们回过头来看看 DexPathList 这个对象,这个对象持有 dexElements 和 nativeLibraryPathElements 这两个属性,也就是说它保存了 dex 和 本地方法库。

为了加深大家对DexPathList的理解,我们来看看官方的说明。
A pair of lists of entries, associated with a {@code ClassLoader}. One of the lists is a dex/resource path — typically referred to as a “class path” — list, and the other names directories containing native code libraries. Class path entries may be any of: a {@code .jar} or {@code .zip} file containing an optional top-level {@code classes.dex} file as well as arbitrary resources, or a plain {@code .dex} file (with no possibility of associated resources).</br>This class also contains methods to use these lists to look up classes and resources.

大概的意思就是 DexPathList 的作用和 JVM 中的 classpath 的作用类似,JVM 根据 classpath 来查找类,而 Dalvik 利用 DexPathList 来查找并加载类。DexPathList 包含的路径可以是 .dex 文件的路径,也可以是包含了 dex 的 .jar 和 .zip 文件的路径。

BaseClassLoader 加载器的类加载过程

我们知道,一个类加载器的入口方法是 loadClass()。这是Java语音所共有的。类加载器通过findClass()找到所需要加载的类。
protected Class<?> loadClass(String className, boolean resolve) throws ClassNotFoundException {
        Class<?> clazz = findLoadedClass(className);

        if (clazz == null) {
            ClassNotFoundException suppressed = null;
            try {
                clazz = parent.loadClass(className, false);
            } catch (ClassNotFoundException e) {
                suppressed = e;
            }

            if (clazz == null) {
                try {
                    clazz = findClass(className);
                } catch (ClassNotFoundException e) {
                    e.addSuppressed(suppressed);
                    throw e;
                }
            }
        }

        return clazz;
    }

BaseDexClassLoader 也继承自 ClassLoader,因此我们就从 findClass() 方法来分析下 BaseClassLoader 加载类的过程。
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
       List<Throwable> suppressedExceptions = new ArrayList<Throwable>();
       Class c = pathList.findClass(name, suppressedExceptions);
       if (c == null) {
           ClassNotFoundException cnfe = new ClassNotFoundException("Didn't find class \"" + name + "\" on path: " + pathList);
           for (Throwable t : suppressedExceptions) {
               cnfe.addSuppressed(t);
           }
           throw cnfe;
       }
       return c;
}
这个方法极其简单,主要风格findclass找到类 Class c = pathList.findClass(name, suppressedException)这里BaseClassLoader 把查找类的任务委托给了 pathList。那么我们来看一下Android的DexPathList的findClass又做了什么事情。
public Class findClass(String name, List<Throwable> suppressed) {
       for (Element element : dexElements) {
           DexFile dex = element.dexFile;

           if (dex != null) {
               Class clazz = dex.loadClassBinaryName(name, definingContext, suppressed);
               if (clazz != null) {
                   return clazz;
               }
           }
       }
       if (dexElementsSuppressedExceptions != null) {
           suppressed.addAll(Arrays.asList(dexElementsSuppressedExceptions));
       }
       return null;
}

它遍历了 dexElements 中的所有 DexFile通过 DexFile 的loadClassBinaryName() 方法加载目标类dexElements 又把查找类的任务委托给了DexFile
private DexFile(String sourceName, String outputName, int flags, ClassLoader loader,
           DexPathList.Element[] elements) throws IOException {
       if (outputName != null) {
           try {
               String parent = new File(outputName).getParent();
               if (Libcore.os.getuid() != Libcore.os.stat(parent).st_uid) {
                   throw new IllegalArgumentException("Optimized data directory " + parent
                           + " is not owned by the current user. Shared storage cannot protect"
                           + " your application from code injection attacks.");
               }
           } catch (ErrnoException ignored) {
               // assume we'll fail with a more contextual error later
           }
       }

       mCookie = openDexFile(sourceName, outputName, flags, loader, elements);
       mFileName = sourceName;
       //System.out.println("DEX FILE cookie is " + mCookie + " sourceName=" + sourceName + " outputName=" + outputName);
}
到这里我们就已经很明白了,openDexFile调用openDexFileNative()方法,(
mCookie = openDexFile(sourceName, outputName, flags, loader, elements);

它做的事就是把对应的 dex 文件加载到内存中,然后返回给 java 层一个类似句柄一样的东西 Object:mCookie。
在构造方法中 DexFile 就完成了 dex 文件的加载过程。现在我们回到 DexFile 对象的loadClassBinaryName()
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 {
           result = defineClassNative(name, loader, cookie, dexFile);
       } catch (NoClassDefFoundError e) {
           if (suppressed != null) {
               suppressed.add(e);
           }
       } catch (ClassNotFoundException e) {
           if (suppressed != null) {
               suppressed.add(e);
           }
       }
       return result;
}

看到这里我们明白了,class 对象在 java 层加载过程的尽头就是这个 defineClass() 方法,这个方法调用本地法 defineClassNative() 从 dex 中查找目标类,如果找到了,就把这个代表这个类的 Class 对象返回到此,Android的加载过程我们终于看完了。
到这里我们回头看看Android的两个类加载器:DexClassLoader() 和 PathClassLoader()。
DexClassLoader 用来加载 .dex 文件以及包含 dex 文件的 .jar、.zip 和未安装的 .apk 文件,因此需要指定优化后的 dex 文件的输出路径;
PathClassLoader 一般用来加载已经安装到设备上的.apk,因为应用在安装的时候已经对 apk 文件中的 dex 进行了优化,并且会输出到 /data/dalvik-cache 目录下(android M 在这目录下找不到,应该是改成了 /data/app/com.example.app-x/oat 目录下),所以它不需要指定优化后 dex 的输出路径。
常用的插件化和动态加载都是基于DexClassLoader来实现的,如果有需要的请点链接   点击打开链接

版权声明:本文内容由阿里云实名注册用户自发贡献,版权归原作者所有,阿里云开发者社区不拥有其著作权,亦不承担相应法律责任。具体规则请查看《阿里云开发者社区用户服务协议》和《阿里云开发者社区知识产权保护指引》。如果您发现本社区中有涉嫌抄袭的内容,填写侵权投诉表单进行举报,一经查实,本社区将立刻删除涉嫌侵权内容。

相关文章
android加载大图,防止oom
高效加载大图片 我们在编写Android程序的时候经常要用到许多图片,不同图片总是会有不同的形状、不同的大小,但在大多数情况下,这些图片都会大于我们程序所需要的大小。比如说系统图片库里展示的图片大都是用手机摄像头拍出来的,这些图片的分辨率会比我们手机屏幕的分辨率高得多。大家应该知道,我们编写的应用程序都是有一定内存限制的,程序占用了过高的内存就容易出现OOM(OutOfMemory)
1182 0
【Android 应用开发】Activity生命周期 与 Activity 之间的通信
【Android 应用开发】Activity生命周期 与 Activity 之间的通信
28 0
Android Activity调整改变成Dialog
做一个style配置到style.xml,直接作为该Activity的android:theme配置即可。 具体的style: true @android:color/transparent true true true @android:color/transparent 0.5 在上层Java代码增加一个: setTitle(null);取消系统自己设置的标题。
971 0
Android开发之入口Activity
原文:Android开发之入口Activity Android开发之入口Activity Adnroid App是如何确定入口Activity的? 难道就因为class的类名叫MainActivity,布局文件叫activity_main.xml? 如果这样认为,就大错特错了。
941 0
Android开发之浮动Activity
场景 在使用App时,曾经看到这样一个场景,如下图所示,点击顶部菜单按钮,有一个类似的对话框的列表显示出来,让用户选择其中的一个快递选项,然后选中的快递信息就会填充到底部的Activity中。
674 0
我的Android进阶之旅------&gt;如何将Activity变为半透明的对话框?
              我的Android进阶之旅------>如何将Activity变为半透明的对话框?可以从两个方面来考虑:对话框和半透明。 在定义Activity时指定Theme.Dialog主题就可以将Activity设置为对话框风格。
1296 0
我的Android进阶之旅------&gt;自己写个Activity来调节Android系统背光亮度Brightness
            今天终于算初步写好了一个调节系统背光亮度Brightness的代码,本来不看Android源代码以为可以直接调用某个Action来启动系统的那个调节Brightness的对话框,但是看了代码后发现系统调节Brightness的自定义组件BrightnessPreference继承了SeekBarDialogPreference,但是最终父类还是Preference,因此不能够单独启动,必须得将这个自定义的BrightnessPreference用某个Activity才能加载过来,毕竟最终还是一个组件。
971 0
+关注
code_xzh
对前端移动客户端技术比较擅长。著有《React Native移动开发实战》和《Kotlin入门与实战》和《Weex跨平台实战》,《React Native移动开发进阶》即将出版,正在努力完成《Flutter跨平台开发实战》
736
文章
1
问答
文章排行榜
最热
最新
相关电子书
更多
低代码开发师(初级)实战教程
立即下载
阿里巴巴DevOps 最佳实践手册
立即下载
冬季实战营第三期:MySQL数据库进阶实战
立即下载