安卓動態加載入門

安卓動態加載入門

這幾周為了理解安卓動態加載技術算是花了不少時間,遇到很多坑,當然也學到了不少。一開始是學習 java 虛擬機,了解類文件格式,然后又在各種博客網站上看 dalvik 虛擬機和 dex 文件格式,了解安卓的類加載機制,到后來又去了解 art 虛擬機和 oat 文件格式。雖然有些地方沒搞太清楚,學習的不夠深入,但總算把動態加載的大概原理弄清了,也算是為之后更深入學習安卓動態加載以及熱修復、熱更新等技術打下基礎吧。

什么是動態加載技術

這個在網上沒有看到嚴格的定義,不過就我個人的理解,動態加載代碼就是通過在運行時加載外部代碼(磁盤,網絡等)改變程序行為的技術。關于安卓動態加載技術的文章網上有很多,但很多都是基于較低安卓版本的,對于較高版本有些地方不一定適用。我這里準備基于 andriod M 來和大家分享一下安卓的動態加載技術,讓大家對這項技術有一個初步的了解。

動態加載技術詳解

不管是 java 應用還是安卓應用,動態加載技術的核心都是類加載機制,所以我們有必要先了解下安卓的類加載機制,而安卓的類加載機制沿襲了普通的 java 應用的類加載機制,因此我們先看看 java 虛擬機(JVM)是怎么加載類的。

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() 等進行比較時總是返回 true。

我們知道,安卓應用和普通的 java 應用不同,它們運行于 Dalvik 虛擬機。JVM 是基于棧的虛擬機,而 Dalvik 是基于寄存器的虛擬機。因此,java 虛擬機具有更大的指令集,而 Dalvik 虛擬機的指令更長。除此之外,考慮到 Dalvik 虛擬機運行于移動設備,內存空間和 CPU 執行效率有限,因此采用 dex 作為儲存類字節碼信息的文件。當 java 程序編譯成 class 后,編譯器會使用 dx 工具將所有的class 文件整合到一個 dex 文件,目的是使其中各個類能夠共享數據,在一定程度上降低了冗余,同時也是文件結構更加緊湊。雖然這兩種虛擬機有諸多不同,但是 Dalvik 繼承了 JVM 的委派式的類加載機制,因此上面的 部分 (主要是后面四條)結論對于安卓來說也是同樣適用的。

因為安卓的類加載機制也是委派式的,所以如果你知道 JVM 的類加載機制,那么通過類比學習安卓的類加載機制就很容易了。本來準備放張圖來對比說明安卓的類加載模型的,但是想想我們還是有必要先了解安卓中兩個重要的類加載器以及內部的細節: DexClassLoader 和 PathClassLoader 。

我們可以做如下類比:

  • Dalvik 類比于 JVM
  • dex 文件 類比于 class 文件
  • 類加載路徑( classpath ) 類比于 dex 文件的路徑( DexPathList , 這個后面會講到

DexClassLoader & PathClassLoader

先看看這兩個類加載器的定義(點擊超鏈接可查看注釋):

  • DexCloassLoader

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);
}

}</code></pre>

  • PathClassLoader

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);
}

}</code></pre>

可以看到,這兩個類加載器都是繼承自 BaseDexClassLoader ,只是分別實現了自己的構造方法。那么我們自然對這個 BaseDexClassLoader 很感興趣,看看它的構造方法:

public BaseDexClassLoader(String dexPath, File optimizedDirectory, String librarySearchPath, ClassLoader parent) {
        super(parent);
        this.pathList = new DexPathList(this, dexPath, librarySearchPath, optimizedDirectory);
}

說下這個構造方法幾個參數:

  • 第一個參數指的是我們要加載的 dex 文件的路徑,它有可能是多個 dex 路徑,取決于我們要加載的 dex 文件的個數,多個路徑之間用 : 隔開。
  • 第二個參數指的是優化后的 dex 存放目錄。實際上,dex 其實還并不能被虛擬機直接加載,它需要系統的優化工具優化后才能真正被利用。優化之后的 dex 文件我們把它叫做 odex (optimized dex,說明這是被優化后的 dex)文件。其實從 class 到 dex 也算是經歷了一次優化,這種優化的是機器無關的優化,也就是說不管將來運行在什么機器上,這種優化都是遵循固定模式的,因此這種優化發生在 apk 編譯。而從 dex 文件到 odex 文件,是機器相關的優化,它使得 odex 適配于特定的硬件環境,不同機器這一步的優化可能有所不同,所以這一步需要在應用安裝等運行時期由機器來完成。
  • 第三個參數的意義是庫文件的的搜索路徑,一般來說是 .so 庫文件的路徑,也可以指明多個路徑。
  • 第四個參數就是要傳入的父加載器,一般情況我們可以通過 Context#getClassLoader() 得到應用程序的類加載器然后把它傳進去。

這個構造函數的意義很簡單,它做了兩件事:連接了父加載器;構造了一個 DexPathList 實例保存在 pathList 中。這個 pathList 現在我們還不知道它是何方神圣,但是我們通過類名隱約的感覺到它保存了 Dalvik 虛擬機要加載的 dex 文件的路徑,實際情況如何呢?我們看看這個類:

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;
   }

}</code></pre>

這個構造方法也很簡單,這里我們主要看這幾行代碼:

this.dexElements = makeDexElements(splitDexPath(dexPath), optimizedDirectory,
                              suppressedExceptions, definingContext);
...
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);</code></pre>

這幾行代碼做的事也很清晰明了,就是給兩個字段賦值。一個是 dexElements ,另一個是 nativeLibraryPathElements 。我們來看看這兩個字段是怎么得到的:

  • dexElements 是通過 makeDexElements() 方法得到的,我們主要關注這個方法的前兩個參數。第二個參數前面已經說了,是 dex 文件優化后的存放目錄。第一個參數是通過 splitDexPath() 得到的,這個方法方法最終會調用 splitPaths() ,所以我們看看 splitPaths() 是怎樣的:
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;

}</code></pre>

這個方法做的事正如其名字所表達的,就是把用 : 分隔的路徑分割后保存為 File 類型的列表返回。現在看看 makeDexElements() 這個方法:

private static Element[] makeDexElements(List<File> files, File optimizedDirectory,
                                            List<IOException> suppressedExceptions,
                                            ClassLoader loader) {
     return makeElements(files, optimizedDirectory, suppressedExceptions, false, loader);
}

就是利用已有參數簡單調用了 makeElements() ,其中, ignoreDexFiles 傳入的是 false , makeElements() 的實現:

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;

}</code></pre>

這個方法的名字也很好的說明了它要做的事,就是裝配 Element 數組。裝配 Element 數組的工作主要在 for 循環中,除了異常情況,它的每一次循環都構造了一個 Element 。 Element 是什么東西?你可以大概的把它理解為一個實體類。忽略異常情況,我們現在來分析這些 Element 是如何構造的,首先循環的開始部分定義了構造 Element 要用到的參數,然后對傳入的每個 File 判斷其類型:

  • 第一個判斷我也沒看太懂,不知道為什么這么做,好在這不是重點,我們往后看。

  • 第二個判斷是,如果文件是一個目錄,那么直接把這個目錄傳入 Element 的構造方法構造一個 Element ;如果不是就進行下一個判斷。

  • 第三個判斷中又有兩個判斷:
  • 根據后綴看它是不是 dex 文件,如果是,那么就通過 loadDexFile() 來加載一個 DexFile 對象(這個 DexFile 是什么我們等下再講,你可以把它理解為一個對應著一個 dex 文件的對象)。如果成功加載了,那么就把它傳入 Element 構造方法構造一個 Element 。
  • 如果不是 dex 文件,那么不管它什么后綴名,都把它看作是一個 zip,前提是它必須是一個 zip 格式的文件(如 zip,jar,apk),并且這個 zip 格式的文件必須要包含一個 dex 文件,同時這個文件須位于 zip 內部的根目錄下。然后又會利用這個 zip 文件加載一個 DexFile 對象。最后將這個 zip 和連同加載出來的 DexFile 對象一起傳入 Element 的構造方法構造一個 Element 對象。

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 。關于 DexFile 內部的細節到時候分析類加載過程的時候會講,這里就不細說了。

通過前面的分析我們知道,我們可以知道 dexElements 主要作用就是用來保存和 dex 文件對應的 DexFile 對象的。

  • nativeLibraryPathElements 產生的方法和 pathList 差不多,它保存的主要是本地方法庫(本地方法庫的存在形式一般是 .so 文件)對應的對象,包括應用程序的本地方法庫和系統的本地方法庫。這里就不對它過多講解了。

分析完這兩字段,現在我們回過頭來看看 DexPathList 這個對象,這個對象持有 dexElements 和 nativeLibraryPathElements 這兩個屬性,也就是說它保存了 dex 和 本地方法庫。dex 和 本地方法庫分別保存著 java,這樣的話如果我們的類加載器要加載某個類的話,是不是只要操作這個對象就可以了呢?事實上的確如此,我們看看 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 文件的路徑。

現在我們看看 BaseDexClassLoader 是如何加載類的。

BaseClassLoader 加載類的過程

我們知道,一個類加載器的入口方法是 loadClass() :

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;
}</code></pre> 

這個方法封裝了委派式加載機制,所以一般不重寫。 CLassLoader 的子類通常重寫 findClass() 來定義自己的類加載策略。 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;
}

這個方法的重點就是 Class c = pathList.findClass(name, suppressedException) , pathList 很熟悉對不對?它就是前面分析的 BaseDexClassLoader 中的 DexPathList 對象。這里 BaseClassLoader 把查找類的人物委托給了 pathList 。

我們看看 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 ,看來 DexFile 這個對象的地位最低,大佬們都假裝把活干完了,暗地里卻把活丟給了它。前面說了, DexFile 對應著一個 dex 文件(或者包含 dex 文件的 zip 格式文件),那么我們看看他是怎樣在對應的 dex 文件中查找類的。

首先分析它的構造方法:

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() ,這家伙是個本地方法,我們就不 深究 了。它做的事就是把對應的 dex 文件加載到內存中,然后返回給 java 層一個類似句柄一樣的東西 Object:mCookie ,我不知道這樣說準不準確,但是后續的操作包括從 dex 文件中加載目標類和關閉 DexFile 對象釋放資源都用到了這個 mCookie 。此外,這個本地方法還做了一件重要的事,那就是優化 dex 并將其輸出到指定文件夾。

在構造方法中 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 對象返回。至此,Dalvik 虛擬機加載類的整個過程就結束了。現在我們回過頭看看 DexClassLoader() 和 PathClassLoader() ,這兩個類加載器的唯一區別就是前者指定了優化后的 dex 文件的輸出路徑,后者沒有指定。也就這一點差異造成了它們不同的使用場景: DexClassLoader 用來加載 .dex 文件以及包含 dex 文件的 .jar、.zip 和未安裝的 .apk 文件,因此需要指定優化后的 dex 文件的輸出路徑; PathClassLoader 一般用來加載已經安裝到設備上的 .apk ,因為應用在安裝的時候已經對 apk 文件中的 dex 進行了優化,并且會輸出到 /data/dalvik-cache 目錄下(android M 在這目錄下找不到,應該是改成了 /data/app/com.example.app-x/oat 目錄下),所以它不需要指定優化后 dex 的輸出路徑。下面用一張圖來總結下安卓的類加載機制:

對這個模型相信現在大家對安卓的類加載機制有了大概的了解。

來自:http://liwenkun.xyz/2016/11/11/android-load-class-dynamically/

 

 本文由用戶 java_lyf18 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!