Dalvik類的加載-源碼閱讀筆記

前言

本文主要研究Android dalvik虛擬機加載類的流程和機制。目的是了解Android中DEX文件結構,虛擬機如何從DEX文件中加載一個Java Class,以及到最終如何初始化這個類直至可被正常使用。

?

[Java]類的加載

在Java的世界里,所有類的加載,都由 java.lang.ClassLoader 來負責。ClassLoader是一個抽象類,它有多個實現類,例如 BootClassLoader , SystemClassLoader 以及虛擬機的具體實現,例如在dalvik虛擬機里的實現為 DexClassLoader

需要注意的每個虛擬機對于類的加載的邏輯并不十分相同,例如hotspot虛擬機和dalvik虛擬機加載一個類的過程基本上完全不同,hotspot主要是從class文件從加載類,而dalvik是從dex文件里去加載一個類,所以這里只討論的是dalvik虛擬機里的實現機制。

?

雙親委派機制

不管虛擬機的具體實現,但虛擬機spec定義的關于類的加載規范必須被實現,例如最基礎的雙親委派機制。

它規定每個ClassLoader都得有一個父親ClassLoader,以此形成一個父子的多層級關系,利用這個層級關系實現了類的雙親委派機制。

在dalvik里的ClassLoader層級關系如下:

  • Bootstrap ClassLoader
  • System ClassLoader
  • Dex ClassLoader

?

在任何一個ClassLoader加載一個類的時候,都會先委托其父ClassLoader來負責加載這個類,一直遞歸到最頂層的ClassLoader,這個設計主要應該是為了安全考慮。以保障上層的類被上層的ClassLoader來加載,而避免系統類被下層的ClassLoader給替換掉了,而引發安全問題。

?

例如 DexClassLoader 加載類的時候,會先委托 SystemClassLoader 來加載該類,而 SystemClassLoader 又會先讓它的父親 BootClassLoader 先來加載,如果其所有祖父們都不加載該類,才會由這個ClassLoader去加載。

具體的代碼實現為:

    protected Class<?> loadClass(String name, boolean resolve)
        throws ClassNotFoundException
    {
            // First, check if the class has already been loaded
            Class c = findLoadedClass(name);
            if (c == null) {
                long t0 = System.nanoTime();
                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) {
                    // If still not found, then invoke findClass in order
                    // to find the class.
                    long t1 = System.nanoTime();
                    c = findClass(name);

                    // this is the defining class loader; record the stats
                }
            }
            return c;
    }

源碼來至 : /java/lang/ClassLoader.java

源碼簡單解讀:

  • 先檢查這個類是否被加載過,如果已經被加載過,則直接返回,不重新加載該類。(注意這里已經加過的類的列表并沒有存儲到Java層面,而是直接去問native層這個類是否被加載過,它維護了所有加載過的類)
  • 遞歸委派父ClassLoader去加載。
  • 如果所有父ClassLoader都不加載,自己才有權利去加載該類。
  • 先去找到該Class(具體的findClass邏輯由子類實現)

?

類加載器(ClassLoader)

ClassLoader作為基類,關鍵的方法都交給子類具體去實現了,但它定義了類的加載過程:

load -> find -> define -> resolve

Class<?> loadClass(String name, boolean resolve);

Class<?> findClass(String name);

Class<?> defineClass(String name, byte[] b, int off, int len, ProtectionDomain protectionDomain);

void resolveClass(Class<?> c);

?

SystemClassLoader

SystemClassLoader是一個ClassLoader默認的parent,即在創建一個ClassLoader時,不傳入parent參數,則默認會使用這個SystemClassLoader作為其parent。

它其實是ClassLoader的一個靜態內部類,里面包含了一個默認的ClassLoader,也是由ClassLoader的靜態方法來創建的:

    static private class SystemClassLoader {
        public static ClassLoader loader = ClassLoader.createSystemClassLoader();
    }

     /**
     * Encapsulates the set of parallel capable loader types.
     */
    private static ClassLoader createSystemClassLoader() {
        String classPath = System.getProperty("java.class.path", ".");
        String librarySearchPath = System.getProperty("java.library.path", "");
        return new PathClassLoader(classPath, librarySearchPath, BootClassLoader.getInstance());
    }

    public static ClassLoader getSystemClassLoader() {
        return SystemClassLoader.loader;
    }

由代碼可以看出 SystemClassLoader其實是一個全局靜態的單例類,并且它的parent為 BootClassLoader。

?

BootClassLoader

BootClassLoader也定義在ClassLoader類,但外部不能訪問到。也是一個單例類。它是ClassLoader的子類。它是唯一一個沒有parent的ClassLoader

class BootClassLoader extends ClassLoader {

    private static BootClassLoader instance;

    @FindBugsSuppressWarnings("DP_CREATE_CLASSLOADER_INSIDE_DO_PRIVILEGED")
    public static synchronized BootClassLoader getInstance() {
        if (instance == null) {
            instance = new BootClassLoader();
        }

        return instance;
    }

    public BootClassLoader() {
        super(null);
    }
    
    // ...
}

其中loadClass里,先查找已經加載的類,由以下方法來實現,該方法是native方法 :

VMClassLoader#findLoadedClass(ClassLoader cl, String name);

然后findClass是由 Class.classForName 來實現的,也是一個native方法。

?

?

[Android]類的加載

加載Dex(DexClassLoader)

在dalvik里面的ClassLoader主要是由 DexClassLoaderPathClassLoader 來完成,這兩個類屬于 android/platform/libcore 項目,源碼可查看AOSP:

https://android.googlesource.com/platform/libcore/+/master/dalvik/src/main/java/dalvik/system/

?

DexClassLoader 繼承 dalvik.system.BaseDexClassLoader ,只提供一個構造器,并沒有實現代碼,主要代碼還是在父類里面。

我們現在看 BaseDexClassLoader ,它繼承于 ClassLoader. 先來看它的構造器:

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

這里需要傳四個參數,也基本能看出這個類的主要結構。

  • dexPath: 這個參數的名字雖然是一個path結尾的,但不是傳一個目錄,而是傳 .dex .zip,.apk,.jar的文件絕對路徑,但可以一次傳多個,用 : 作為分隔即可。
  • optimizedDirectory: 這個目錄是dex的優化目錄,必須是當前用戶,并且可寫.(也可以為 null,系統會使用默認目錄,一般是 /data/app/dalvik-cache)
  • librarySearchPath: native庫的搜索目錄,可以用 : 作為分隔符傳入多個目錄,也可以傳入 null .
  • parent: parent ClassLoader

這里面關鍵的參數都傳給了 DexPathList 對象了,可見大多數邏輯都會交給它來處理。

?

findClass

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

如推測的一樣,具體的邏輯都交給 DexPathList 去實現了。那么接下來我們就來研究它。需要注意的是 BaseDexClassLoader 只重寫了 findClass 這個方法,而沒有重寫 loadClass , defineClassresolveClass !

?

Dex列表(DexPathList)

這個類時傳入的 dexPath 的抽象,因為dexPath可能會傳入用 : 分隔的多個apk文件,而每個apk文件中又可能有多個dex文件,因此 DexPathList 包含了所有apk文件里面的所有dex文件的封裝。并將每個Dex文件抽象成 DexFile 對象,包裹在 DexPathList#Element 列表中。

private Element[] dexElements;

在處理dexPath的時候,首先從 : 分隔符split成數組,然后遍歷這些文件,將其解析成 DexFile 對象,并封裝到 Element 中。

for (File file : files) {
    // ...
    if (name.endsWith(DEX_SUFFIX)) {
        // .dex
        DexFile dex = loadDexFile(file, optimizedDirectory, loader, elements);
        if (dex != null) {
            elements[elementsPos++] = new Element(dex, null);
        }
    } else { 
        // .zip .jar
        // ...
        DexFile dex = loadDexFile(file, optimizedDirectory, loader, elements);
        // ...
    }
    // ...
}

?

在調用 findClass 的時候,會遍歷這些DexFile文件,從Dex中尋找具體的class:

for (Element element : dexElements) {
    Class<?> clazz = element.findClass(name, definingContext, suppressed);
    if (clazz != null) {
        return clazz;
    }
}

?

除了傳入一個dexPath,還可以傳入一個 ByteBuffer 數組,每個byteBuffer里面包含了一個dex文件的字節流。并且 optimizedDirectory 參數是可以為 NULL 的。

?

除了dex文件,DexPathList還負責管理所有的native庫,并將其也維護在一個列表中:

/** List of native library path elements. */
private final NativeLibraryElement[] nativeLibraryPathElements;

nativeLibrary的searchPath可以是一個普通路徑,也可以是一個zip的路徑。

當查找一個library的時候,會去從這些目錄下尋找文件,例如:findLibrary(String filename) ,那么會去遍歷這些目錄,直到找到 path/filename 存在(并可讀)的時候,則返回該library文件(如so文件)

?

尋找Class文件(DexFile)

dalvik.system.DexFile 這個類是對 Dex文件的抽象,具體在Dex文件中尋找Class定義的工作,則是由這個類來處理。

首先它會將dex文件打開,并讀取成VM cookie object對象(具體的讀取dex邏輯是由native方法實現)。

?

public Class loadClass(String name, ClassLoader loader) {
    String slashName = name.replace('.', '/');
    return loadClassBinaryName(slashName, loader, null);
}

private static native Class defineClassNative(String name, ClassLoader loader, Object cookie,
                                              DexFile dexFile)
            throws ClassNotFoundException, NoClassDefFoundError;

至于真正的 loadClass() 邏輯其實還是在defineClassNative() 這個native方法里完成的。

?

到此為止,Java層的關于關于class的加載邏輯基本已經了解,具體的很多工作都是native層是去完成,因此接下來我們來研究native層的。

?

?

[c++]類的加載

讀取DEX文件(openDexFile)

這里我們接著上面 DexFile 的 native 方法來看,c++的源碼在:

\dalvik2\vm\native\dalvik_system_DexFile.cpp

首先在native層會將dex文件打開并映射到內存中:

static void Dalvik_dalvik_system_DexFile_openDexFile(const u4* args,
    JValue* pResult)
{
  // ...
  DexOrJar* pDexOrJar = NULL;
  // ...
  if (hasDexExtension(sourceName)
            && dvmRawDexFileOpen(sourceName, outputName, &pRawDexFile, false) == 0) {
        ALOGV("Opening DEX file '%s' (DEX)", sourceName);

        pDexOrJar = (DexOrJar*) malloc(sizeof(DexOrJar));
        pDexOrJar->isDex = true;
        pDexOrJar->pRawDexFile = pRawDexFile;
        pDexOrJar->pDexMemory = NULL;
  }
  // ...
  RETURN_PTR(pDexOrJar);
}

讀取dex文件的邏輯在 dvmRawDexFileOpen 函數中,它會去讀取dex文件,并將其內部數據映射到一塊只讀的共享內存中去。具體負責內存映射的邏輯在 dexFileParse 函數中。

?

加載類的流程

首先來看,虛擬機對于一個類的加載流程,分為如下幾個狀態,從中大概能看到整個流程都經過了什么。

類的加載狀態 ClassStatus :

name value note
CLASS_ERROR -1
CLASS_NOTREADY 0
CLASS_IDX 1 loaded, DEX idx in super or interfaces
CLASS_LOADED 2 DEX idx values resolved
CLASS_RESOLVED 3 part of linking
CLASS_VERIFYING 4 in the process of being verified
CLASS_VERIFIED 5 logically part of linking, done pre-init
CLASS_INITIALIZING 6 class init in process
CLASS_INITIALIZED 7 ready to go

?

?

這個函數比較關鍵,它主要負責在dex文件去中查找和加載class,但也比較長,因此在此做省略操作,只保留關鍵部分,我們從中提取關鍵點進行分析:

static void Dalvik_dalvik_system_DexFile_defineClass(const u4* args,
    JValue* pResult)
{
    // ...

    if (pDexOrJar->isDex)
        pDvmDex = dvmGetRawDexFileDex(pDexOrJar->pRawDexFile);
    else
        pDvmDex = dvmGetJarFileDex(pDexOrJar->pJarFile);
    
    // ...

    clazz = dvmDefineClass(pDvmDex, descriptor, loader);
    // ...
}

這個函數前面做了一些轉換工作就不分析了,首先它會去拿到可以映射到內存的dex文件,關鍵的一句在于調用 dvmDefineClass 函數,該函數會從dex里去查找和加載class。

?

該函數定義在 dalvik2/vm/oo/Class.cpp 文件中,具體的函數為:

static ClassObject* findClassNoInit(const char* descriptor, Object* loader,
    DvmDex* pDvmDex)
{
    // 在已經加載過的類中去尋找,如果已經加載過,則不用再進行加載了
    clazz = dvmLookupClass(descriptor, loader, true);
    if (clazz == null) {
        // 在Dex文件中尋找Class定義
        pClassDef = dexFindClass(pDvmDex->pDexFile, descriptor);
      
        // 找到了Class的定義后,將其加載成ClassObject對象
        ClassObject* clazz = loadClassFromDex(pDvmDex, pClassDef, loader);
        // ...
      
        // 記錄該類到已加載過類的hash table中,便于下次檢查
        dvmAddClassToHash(clazz);
        // ...
        
        // 鏈接這個Class
        dvmLinkClass(clazz);
    }
}

這個函數則是最為核心的邏輯,我們詳細來分析:

首先,查找已經加載過的類,所有加載過的類都會將其classDescriptor(類的描述,類似于 Ljava.lang.Object; 這種字符串),將這個類的描述符進行hash,并作為加載的ClassObject的鍵丟到一個hash table里面去,每次加載一個類之前,都會先去這個hash table里面去找一下,看之前有沒有加載過該類,如果加載過則不重復加載,直接從這個hash table里面返回ClassObject對象。否則才會去加載該類。

?

第一步:尋找

如果這個類從來沒有加載過,則從Dex文件中去尋找Class的定義,這個過程是交給 DexFile.cpp 源碼里的 dexFindClass 函數去完成的。

這個類也比較關鍵,也不太好理解,因為貼出完整的代碼,我們仔細分析:

/*
 * 通過類描述符查找一個類的定義 
 *
 * 類描述符"descriptor"應該例如:"Landroid/debug/Stuff;".
 */
const DexClassDef* dexFindClass(const DexFile* pDexFile,
    const char* descriptor)
{
    const DexClassLookup* pLookup = pDexFile->pClassLookup;
    u4 hash;
    int idx, mask;

    hash = classDescriptorHash(descriptor);
    mask = pLookup->numEntries - 1;
    idx = hash & mask;

    /*
     * 遍歷DexClassLookup,直到找到Class的定義
     */
    while (true) {
        int offset;

        offset = pLookup->table[idx].classDescriptorOffset;
        if (offset == 0)
            return NULL;

        // 先比對類描述符的hash值
        if (pLookup->table[idx].classDescriptorHash == hash) {
            const char* str;

            str = (const char*) (pDexFile->baseAddr + offset);
            // hash值匹配后,再比對類描述符的字符串
            if (strcmp(str, descriptor) == 0) {
                // 找到匹配指定描述符的類
                return (const DexClassDef*)
                    (pDexFile->baseAddr + pLookup->table[idx].classDefOffset);
            }
        }

        idx = (idx + 1) & mask;
    }
}

該函數主要描述了如何從一個dex文件中去尋找一個類的定義。

?

第二步:裝載

根據找到的類的定義ClassDef,加載這個類的信息,去dex文件中去創建一個 ClassObject 對象,并將其存在已經加載過的類的hash table里面,下次find這個class的時候,就不會重復去加載這個class了,直接從hash table里面拿。

這個過程由 loadClassFromDex0 函數負責:

/*
 * Helper for loadClassFromDex, which takes a DexClassDataHeader and
 * encoded data pointer in addition to the other arguments.
 */
static ClassObject* loadClassFromDex0(DvmDex* pDvmDex,
        const DexClassDef* pClassDef, const DexClassDataHeader* pHeader,
        const u1* pEncodedData, Object* classLoader)
{
    newClass = (ClassObject*) dvmMalloc(size, ALLOC_NON_MOVING);
    dvmSetClassSerialNumber(newClass);
    
    // 類的簽名
    newClass->descriptor = descriptor;
    // 類的狀態 -> Loaded
    newClass->status = CLASS_IDX;

    // 父類
    newClass->super = (ClassObject*) pClassDef->superclassIdx;
  
    // 接口列表
    pInterfacesList = dexGetInterfacesList(pDexFile, pClassDef);
    
    // 靜態變量(并設置為默認值0或null)
    newClass->sfieldCount = count;
    for (i = 0; i < count; i++) {
        dexReadClassDataField(&pEncodedData, &field, &lastIndex);
        loadSFieldFromDex(newClass, &field, &newClass->sfields[i]);
    }

    // 成員變量
    for (i = 0; i < count; i++) {
        dexReadClassDataField(&pEncodedData, &field, &lastIndex);
        loadIFieldFromDex(newClass, &field, &newClass->ifields[i]);
    }
    dvmLinearReadOnly(classLoader, newClass->ifields);

    // 成員方法
    newClass->directMethodCount = count;
    newClass->directMethods = (Method*) dvmLinearAlloc(classLoader,
            count * sizeof(Method));
    for (i = 0; i < count; i++) {
        dexReadClassDataMethod(&pEncodedData, &method, &lastIndex);
        loadMethodFromDex(newClass, &method, &newClass->directMethods[i]);
    }
    dvmLinearReadOnly(classLoader, newClass->directMethods);

    // 虛方法(父類方法)
    newClass->virtualMethodCount = count;
    newClass->virtualMethods = (Method*) dvmLinearAlloc(classLoader,
            count * sizeof(Method));
    for (i = 0; i < count; i++) {
        dexReadClassDataMethod(&pEncodedData, &method, &lastIndex);
        loadMethodFromDex(newClass, &method, &newClass->virtualMethods[i]);
    }
    dvmLinearReadOnly(classLoader, newClass->virtualMethods);

    // 字節碼
    newClass->sourceFile = dexGetSourceFile(pDexFile, pClassDef);
}

注意這里的classLoader就是用來加載類的,一般都是java層傳過來的。如果loader是null的話,則會去加載系統class,例如 java.lang.Class 類。

這里會在 heap 里分配一塊內存(使用 dvmMalloc 函數),來創建一個 ClassObject 對象,用于放置類的信息。包括:

  • descriptor 類的簽名
  • 類的字段對象 fieldObject
  • 類的狀態為:CLASS_IDX
  • 類的父類
  • 類的接口列表
  • 類的靜態成員變量信息(設置標準默認值:null和0)
  • 類的成員變量信息
  • 類的方法
  • 類的虛方法(父類方法)
  • 類的字節碼

?

第三步:鏈接

bool dvmLinkClass(ClassObject* clazz) { }

link的過程又分為:

  • 加載 LOADED,父類和接口都為NULL
  • 鏈接 RESOLVED,將父類和接口的dex idx鏈接替換成真正的引用
  • 驗證 VERIFIED,檢查一個類的,例如:
    • 如果該類是 java.lang.Object,則不能再有父類
    • 如果不是java.lang.Object, 則必須有父類
    • 不能繼承final類
    • 不能繼承interface
    • 只能繼承public的類或同一個包下的類
    • 等等

所有這些邏輯都在 java_lang_Class.cpp里的:

?

第四步:初始化

bool dvmInitClass(CLassObject* clazz) { }

初始化之前,必須確保該類已經被驗證過(VERIFIED)如果沒有,則立即先驗證它。

如果沒有優化過類,則先優化類(optimize),

然后做一些驗證工作,和線程安全的操作(因為可能多個線程同時引發初始化某個類,所以會使用當前類鎖對初始化過程加鎖)。

接下來就開始初始化過程:

  • 先遞歸的初始化父類 super class,它的父類的父類,直到它們都先被初始化了。****
  • 初始化靜態常量 static final,它會從dex里面將靜態變量的值拿出去(根據偏移量),賦值到類的靜態變量上。
  • 執行靜態區的代碼 static {},所有寫在靜態區的代碼都會合并到靜態方法里面,該方法的名字為 <clinit>,簽名為 ()V。它被當做一個正常的靜態方法被調用。dvmCallMethod()
  • 到此,如果沒有遇到異常,則該類被視為初始化完畢,可以正常使用,狀態也變為 CLASS_INITIALIZED.

?

DexFile文件結構

我們對虛擬機加載一個類的整個過程基本有了一定了解,因為在加載的過程中,基本都是在和 DexFile 文件在打交道,因此作為擴展知識,我們也順便了解 DexFile 的文件結構。

?

首先要明確 Android 之所以使用 Dex 文件來代替Java里的Jar包文件,主要是為了解決在手機這種存儲空間有限的設備上能更進一步的壓縮空間的考慮。

傳統的jar包文件,里面存儲了一個個分散的class文件,而dex文件其實是將一個個分散的class文件合并成一個文件。

?

因此dex的文件結構基本上和class的文件結構非常相關或相似,所有先來看見看class文件的結構:

一個class文件基本上可以分為幾塊內容:

  • 基本信息(如magic,minor version, major version, access flags, this class, super class等信息)
  • 常量池 Constant Pool,它包含了所有基本類型,string,類名,字段名,方法名,類型等常量
  • 接口列表 Interfaces ,它是該類的所有實現接口的列表,包含了所有接口的class,其中className也是指向常量池里的一個classInfo
  • 字段列表 fields, 它包括該類所有字段的列表,每個字段包括三個信息:Name字段名,Descriptor字段類型簽名,access flags訪問權限(public/private/protected/static/final等)
  • 方法列表 methods, 它包括該類的所有定義的方法的列表,每個方法包括:Name方法名,Descriptor方法簽名,access flags訪問權限,以及具體的字節碼code.

?

接下來我們先來看 DexFile 的定義:

struct DexFile {
    /* directly-mapped "opt" header */
    const DexOptHeader* pOptHeader;

    // DEX文件頭指針,包含所有指針的偏移量和長度
    const DexHeader*    pHeader;
    // 字符串列表指針,UTF-16編碼
    const DexStringId*  pStringIds;
    // 類型列表指針
    const DexTypeId*    pTypeIds;
    // 字段列表指針
    const DexFieldId*   pFieldIds;
    // 方法列表指針
    const DexMethodId*  pMethodIds;
    // 函數原型數據指針,方法聲明的字符串,返回類型和參數列表
    const DexProtoId*   pProtoIds;
    // 類的定義列表指針,類的信息,包括接口,超類,類信息,靜態變量偏移量等
    const DexClassDef*  pClassDefs;
    // 靜態連接數據
    const DexLink*      pLinkData;

    /*
     * These are mapped out of the "auxillary" section, and may not be
     * included in the file.
     */
    const DexClassLookup* pClassLookup;
    const void*         pRegisterMapPool;       // RegisterMapClassPool

    /* points to start of DEX file data */
    const u1*           baseAddr;

    /* track memory overhead for auxillary structures */
    int                 overhead;

    /* additional app-specific data structures associated with the DEX */
    //void*               auxData;
};

可以看出來基本一個Dex就是將多個Class的信息整合到一起,這樣的好處是,例如常量池這些都可以共享,從而減少了整體的存儲空間。

?

?

結語

看完dalvik虛擬機對于類的加載流程的相關源碼以后,對一個類是如何被加載到虛擬機有了一個新的認知,并從中看到Android dalvik是如何實現Java虛擬機spec的等一些細節,和hotspot的實現還是有比較大的區別的,對Dex文件,Class文件的格式也有了一個更直觀的了解。下一步需要了解的應該是虛擬機內存相關的部分。

?

源碼索引:

java/lang/ClassLoader.java

    loadClass(findClass)

    defineClass

    resolveClass
dalvik.system.BaseDexClassLoader.java

    findClass
dalvik2/vm/oo/class.cpp

    loadMethodFromDex
dalvik2/vm/native.cpp

    dvmResolveNativeMethod

    unregisterJNINativeMethods
dalvik2/vm/jni.cpp

    dvmRegisterJNIMethod

?

參考資料:

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容