概述
今天主要来分析Dex动态加载C++语言的部分(Android4.0)
内存加载:
native代码:private static int openDexFile(byte[] fileContents) throws IOException
所对应的实现如下:
static void Dalvik_dalvik_system_DexFile_openDexFile_bytearray(const u4* args , JValue* pResult)
{
ArrayObject* fileContentsObj = (ArrayObject*) args[0];
u4 length;
u1* pBytes;
RawDexFile* pRawDexFile;
DexOrJar* pDexOrJar = NULL;
if (fileContentsObj == NULL) {
dvmThrowNullPointerException("fileContents == null");
RETURN_VOID();
}
length = fileContentsObj->length;
pBytes = (u1*) malloc(length);
if (pBytes == NULL) {
dvmThrowRuntimeException("unable to allocate DEX memory");
RETURN_VOID();
}
memcpy(pBytes, fileContentsObj->contents, length);
if (dvmRawDexFileOpenArray(pBytes, length, &pRawDexFile) != 0) {
LOGV("Unable to open in-memory DEX file");
free(pBytes);
dvmThrowRuntimeException("unable to open in-memory DEX file");
RETURN_VOID();
}
LOGV("Opening in-memory DEX");
pDexOrJar = (DexOrJar*) malloc(sizeof(DexOrJar));
pDexOrJar->isDex = true;
pDexOrJar->pRawDexFile = pRawDexFile;
pDexOrJar->pDexMemory = pBytes;
pDexOrJar->fileName = strdup("<memory>"); // Needs to be free()able.
addToDexFileTable(pDexOrJar);
RETURN_PTR(pDexOrJar);
} 首先来解释fileContentsObj这个对象的内容,从Java层传过来的byte[]类型的一个对象,当到达c++语言层的时候成了两个参数,args和pResult,然后把args这个数组中 的第一个元素直接转化成了ArrayObject对象,接下来显而易见,把这个元素的内容赋给了pBytes对象,那么这个总体的过程可以理解为一个Java层的byte[]类型的对象 转化为c++语言层的一个byte[]类型的对象; 接下来将是对dvmRawDexFileOpenArray(pBytes, length, &pRawDexFile)这个函数的分析,在分析之前,可以一起来看一下这个函数的三个参数,第一个参数和第二个 参数已经不用说了,就是一个byte[]类型的数组,以及这个数组中内容的长度,其实这个内容就是dex的字节流信息,第三个参数是一个RawDexFile的对象,这个对象的数据 结构是这样的: struct RawDexFile { char* cacheFileName; DvmDex* pDvmDex; }; 这里又看到一个新的数据对象DvmDex,再来看一下这个数据结构: struct DvmDex { DexFile* pDexFile; const DexHeader* pHeader; struct StringObject** pResStrings; struct ClassObject** pResClasses; struct Method** pResMethods; struct Field** pResFields; struct AtomicCache* pInterfaceCache; bool isMappedReadOnly; MemMapping memMap; pthread_mutex_t modLock; }; 这里又看到一个新的数据对象DexFile,再来看一下这个数据结构: struct DexFile { const DexOptHeader* pOptHeader; const DexHeader* pHeader; const DexStringId* pStringIds; const DexTypeId* pTypeIds; const DexFieldId* pFieldIds; const DexMethodId* pMethodIds; const DexProtoId* pProtoIds; const DexClassDef* pClassDefs; const DexLink* pLinkData; const DexClassLookup* pClassLookup; const void* pRegisterMapPool; // RegisterMapClassPool const u1* baseAddr; int overhead; }; 这个数据结构如果了解dex文件格式的话,就对这里的一些对象比较清楚了,其实这个DvmDex数据结构中就保存了Dex文件的各个部分的一些相关内容,具体可以看一下Dex文件 的格式,这里不做多详细的介绍 现在回到那个方法,我们继续往下玩dvmRawDexFileOpenArray这个函数, int dvmRawDexFileOpenArray(u1* pBytes, u4 length, RawDexFile** ppRawDexFile) { DvmDex* pDvmDex = NULL; if (!dvmPrepareDexInMemory(pBytes, length, &pDvmDex)) { LOGD("Unable to open raw DEX from array"); return -1; } assert(pDvmDex != NULL); *ppRawDexFile = (RawDexFile*) calloc(1, sizeof(RawDexFile)); (*ppRawDexFile)->pDvmDex = pDvmDex; return 0; } 我们可以看到进入这个函数的第一步就是建立了一个DvmDex类型的对象,并初始化为空,经过前面的分析,我们都应该有这个联系,这个pDvmDex对象不就是包含于RawDexFile 这个数据结构中的吗,再看最后第三行,(*ppRawDexFile)->pDvmDex = pDvmDex;,不用多解释,大家明白了这个赋值操作的含义,现在就来看看这个函数的中间部分到底 做了什么呢,还不是dvmPrepareDexInMemory(pBytes, length, &pDvmDex)这个东西惹的祸吗,我们继续进入其中作战, bool dvmPrepareDexInMemory(u1* addr, size_t len, DvmDex** ppDvmDex) { DexClassLookup* pClassLookup = NULL; if (!rewriteDex(addr, len, false, false, &pClassLookup, ppDvmDex)) { return false; } (*ppDvmDex)->pDexFile->pClassLookup = pClassLookup; return true; } 先来看下DexClassLookup这个数据结构长啥模样吧, struct DexClassLookup { int size; int numEntries; struct { u4 classDescriptorHash; int classDescriptorOffset; int classDefOffset; } table[1]; }; 其实很简单,都是一些最基本的数据类型,也不知道这些代码代码什么意思,黑灯瞎火的,继续向下走吧, 现在来看下rewriteDex这个函数干了些什么, static bool rewriteDex(u1* addr, int len, bool doVerify, bool doOpt, DexClassLookup** ppClassLookup, DvmDex** ppDvmDex) { DexClassLookup* pClassLookup = NULL; u8 prepWhen, loadWhen, verifyOptWhen; DvmDex* pDvmDex = NULL; bool result = false; const char* msgStr = "???"; if (dexSwapAndVerify(addr, len) != 0) goto bail; if (dvmDexFileOpenPartial(addr, len, &pDvmDex) != 0) { LOGE("Unable to create DexFile"); goto bail; } pClassLookup = dexCreateClassLookup(pDvmDex->pDexFile); if (pClassLookup == NULL) goto bail; pDvmDex->pDexFile->pClassLookup = pClassLookup; //doVerify和doOpt的值都为false,所以直接跳转到bail,于是这个函数就又执行完了,下面再回溯到前面为执行完的代码 if (!doVerify && !doOpt) { result = true; goto bail; } prepWhen = dvmGetRelativeTimeUsec(); if (!loadAllClasses(pDvmDex)) goto bail; loadWhen = dvmGetRelativeTimeUsec(); if (!dvmCreateInlineSubsTable()) goto bail; verifyAndOptimizeClasses(pDvmDex->pDexFile, doVerify, doOpt); verifyOptWhen = dvmGetRelativeTimeUsec(); if (doVerify && doOpt) msgStr = "verify+opt"; else if (doVerify) msgStr = "verify"; else if (doOpt) msgStr = "opt"; LOGD("DexOpt: load %dms, %s %dms", (int) (loadWhen - prepWhen) / 1000, msgStr, (int) (verifyOptWhen - loadWhen) / 1000); result = true; bail: if (pDvmDex != NULL) { pDvmDex->pDexFile->pClassLookup = NULL; } if (ppDvmDex == NULL || !result) { dvmDexFileFree(pDvmDex); } else { *ppDvmDex = pDvmDex; } if (ppClassLookup == NULL || !result) { free(pClassLookup); } else { *ppClassLookup = pClassLookup; } return result; } 一看可能先吓了一跳,怎么这个函数一下子多了那么多代码,有点不敢看了,其实我想说这是好事啊,这说明你离成功已经不远啦,战士们,Come On吧,dexSwapAndVerify这个函数就不详细讲了,有兴趣的可以自己详细看,主要是用来交换Dex文件的的字节码的顺序的,这个和你使用的系统处理器是大端的还是小端的有密切 的关系; 接下来看dvmDexFileOpenPartial(addr, len, &pDvmDex)这个函数: int dvmDexFileOpenPartial(const void* addr, int len, DvmDex** ppDvmDex) { DvmDex* pDvmDex; DexFile* pDexFile; int parseFlags = kDexParseDefault; int result = -1; pDexFile = dexFileParse((u1*)addr, len, parseFlags); if (pDexFile == NULL) { LOGE("DEX parse failed"); goto bail; } pDvmDex = allocateAuxStructures(pDexFile); if (pDvmDex == NULL) { dexFileFree(pDexFile); goto bail; } pDvmDex->isMappedReadOnly = false; *ppDvmDex = pDvmDex; result = 0; bail: return result; } 这里可以看到新建了一个DexFile类型的对象,这个主要是用来描述Dex的整个文件的信息,上面已经介绍过了, 接下来看其中的pDexFile = dexFileParse((u1*)addr, len, parseFlags)这个函数, (这里先说明吓,parseFlags这个参数的值是kDexParseDefault赋予的,kDexParseDefault是一个枚举变量,它的值为0,所以parseFlags的值为0;) DexFile* dexFileParse(const u1* data, size_t length, int flags) { DexFile* pDexFile = NULL; const DexHeader* pHeader; const u1* magic; int result = -1; //判断dex文件得长度是不是大于dex头部得长度,如果比头部的长度都小,那就什么都可以免谈了,好吗!!! if (length < sizeof(DexHeader)) { LOGE("too short to be a valid .dex"); goto bail; /* bad file format */ } //如果dex文件长度是合法的,那么可以给这个还未初始化的pDexFile对象进行全0的初始化了 pDexFile = (DexFile*) malloc(sizeof(DexFile)); if (pDexFile == NULL) goto bail; /* alloc failure */ memset(pDexFile, 0, sizeof(DexFile)); //这里主要是用来判断是否这个dex文件是一个优化过的dex文件,如果是优化过的dex文件,那么就执行里面的操作,如果是未优化过的,直接可以跳过这一整个 //if操作了,为了简单起见,暂时就先跳过这个处理dex opt头部的操作吧 if (memcmp(data, DEX_OPT_MAGIC, 4) == 0) { magic = data; if (memcmp(magic+4, DEX_OPT_MAGIC_VERS, 4) != 0) { LOGE("bad opt version (0x%02x %02x %02x %02x)", magic[4], magic[5], magic[6], magic[7]); goto bail; } pDexFile->pOptHeader = (const DexOptHeader*) data; LOGV("Good opt header, DEX offset is %d, flags=0x%02x", pDexFile->pOptHeader->dexOffset, pDexFile->pOptHeader->flags); /* parse the optimized dex file tables */ if (!dexParseOptData(data, length, pDexFile)) goto bail; /* ignore the opt header and appended data from here on out */ data += pDexFile->pOptHeader->dexOffset; length -= pDexFile->pOptHeader->dexOffset; if (pDexFile->pOptHeader->dexLength > length) { LOGE("File truncated? stored len=%d, rem len=%d", pDexFile->pOptHeader->dexLength, (int) length); goto bail; } length = pDexFile->pOptHeader->dexLength; } //好了,刚才我们已经跳过了这个处理dex opt头部的操作,下面我们就继续战斗,离结束不远啦,坚持住阿!!! //下面将来看dexFileSetupBasicPointers(pDexFile,data)这个函数,相信有意识的都应该此刻开始兴奋了,因为这就是关键点了!详细分析请看下面 dexFileSetupBasicPointers(pDexFile, data); pHeader = pDexFile->pHeader; //再一次判断头部的magic的8个字节是否是合法的 if (!dexHasValidMagic(pHeader)) { goto bail; } //这里flags为0,不用再纠结了,跳过吧 if (flags & kDexParseVerifyChecksum) { u4 adler = dexComputeChecksum(pHeader); if (adler != pHeader->checksum) { LOGE("ERROR: bad checksum (%08x vs %08x)", adler, pHeader->checksum); if (!(flags & kDexParseContinueOnError)) goto bail; } else { LOGV("+++ adler32 checksum (%08x) verified", adler); } const DexOptHeader* pOptHeader = pDexFile->pOptHeader; if (pOptHeader != NULL) { adler = dexComputeOptChecksum(pOptHeader); if (adler != pOptHeader->checksum) { LOGE("ERROR: bad opt checksum (%08x vs %08x)", adler, pOptHeader->checksum); if (!(flags & kDexParseContinueOnError)) goto bail; } else { LOGV("+++ adler32 opt checksum (%08x) verified", adler); } } } //这里kVerifySignature为0,不用纠结了,跳过吧 if (kVerifySignature) { unsigned char sha1Digest[kSHA1DigestLen]; const int nonSum = sizeof(pHeader->magic) + sizeof(pHeader->checksum) + kSHA1DigestLen; dexComputeSHA1Digest(data + nonSum, length - nonSum, sha1Digest); if (memcmp(sha1Digest, pHeader->signature, kSHA1DigestLen) != 0) { char tmpBuf1[kSHA1DigestOutputLen]; char tmpBuf2[kSHA1DigestOutputLen]; LOGE("ERROR: bad SHA1 digest (%s vs %s)", dexSHA1DigestToStr(sha1Digest, tmpBuf1), dexSHA1DigestToStr(pHeader->signature, tmpBuf2)); if (!(flags & kDexParseContinueOnError)) goto bail; } else { LOGV("+++ sha1 digest verified"); } } //继续判断dex文件的大小是否合法 if (pHeader->fileSize != length) { LOGE("ERROR: stored file size (%d) != expected (%d)", (int) pHeader->fileSize, (int) length); if (!(flags & kDexParseContinueOnError)) goto bail; } //判断dex文件中累定义的个数 if (pHeader->classDefsSize == 0) { LOGE("ERROR: DEX file has no classes in it, failing"); goto bail; } /* * Success! */ result = 0; bail: if (result != 0 && pDexFile != NULL) { dexFileFree(pDexFile); pDexFile = NULL; } return pDexFile; } *********************************************************************************************************************************** 承接上部分对dexFileSetupBasicPointers(pDexFile, data)的分析: void dexFileSetupBasicPointers(DexFile* pDexFile, const u1* data) { DexHeader *pHeader = (DexHeader*) data; pDexFile->baseAddr = data; pDexFile->pHeader = pHeader; pDexFile->pStringIds = (const DexStringId*) (data + pHeader->stringIdsOff); pDexFile->pTypeIds = (const DexTypeId*) (data + pHeader->typeIdsOff); pDexFile->pFieldIds = (const DexFieldId*) (data + pHeader->fieldIdsOff); pDexFile->pMethodIds = (const DexMethodId*) (data + pHeader->methodIdsOff); pDexFile->pProtoIds = (const DexProtoId*) (data + pHeader->protoIdsOff); pDexFile->pClassDefs = (const DexClassDef*) (data + pHeader->classDefsOff); pDexFile->pLinkData = (const DexLink*) (data + pHeader->linkOff); } 可以看到如此简单的几条语句,难道就是核心部分嘛,是的,可以很开心地告诉你,这是非常核心的部分: Dex总共可以分八个部分,Header,StringID,TypeID,FieldID,MethodID,ProtoID,ClassDefID,CODE,这八个部分本来就是连在一块的大肉饼, 千里迢迢地从java层一直跑到C++层,但是从这一刻起,就在调用完dexFileSetupBasicPointers函数地这一刻起,Dex的整个结构一下子被“大卸八块”了,完全充斥着 pDexFile这个结构体了,也可以很光荣地告诉你要是再想访问Dex中地任何内容,你只需访问pDexFile这个结构体对象即可!!!,好了,我们继续回到上面的下一条语句开始 分析,不要让兴奋冲昏了头脑,毕竟这还没有大获全胜阿,加油,Comn on!!! *********************************************************************************************************************************** 分析完了这个dexFileParse((u1*)addr, len, parseFlags)函数过后,我们可以返回上一层的dvmDexFileOpenPartial这个函数的下一条语句了, 从这里开始:好了,这里我们又要分析一个关键的函数了,刚才把pDexFile喂饱了,那么干脆好人做到底啦,是不是应该要把pDvmDex这个对象也要喂饱呢??if (pDexFile == NULL) { LOGE("DEX parse failed"); goto bail; } pDvmDex = allocateAuxStructures(pDexFile); if (pDvmDex == NULL) { dexFileFree(pDexFile); goto bail; } pDvmDex->isMappedReadOnly = false; *ppDvmDex = pDvmDex; result = 0; bail: return result; }
我们这就来分析allocateAuxStructures(pDexFile)这个函数吧,
static DvmDex* allocateAuxStructures(DexFile* pDexFile)
{
//前三行初始化对象,不用多说了,就等着待会儿被赋值吧
DvmDex* pDvmDex;
const DexHeader* pHeader;
u4 stringCount, classCount, methodCount, fieldCount;
//给pDvmDex这个对象分配内存,赋值为全1
pDvmDex = (DvmDex*) calloc(1, sizeof(DvmDex));
if (pDvmDex == NULL)
return NULL;
//这两句语开始给pDvmDex这个结构体中的变量赋值
pDvmDex->pDexFile = pDexFile;
pDvmDex->pHeader = pDexFile->pHeader;
//这五条语句主要是给这个函数的5个局部变量赋值
pHeader = pDvmDex->pHeader;
stringCount = pHeader->stringIdsSize;
classCount = pHeader->typeIdsSize;
methodCount = pHeader->methodIdsSize;
fieldCount = pHeader->fieldIdsSize;
//下面这四条语句给pDvmDex这个对象中的关键的四个字段进行分配空间
pDvmDex->pResStrings = (struct StringObject**)
calloc(stringCount, sizeof(struct StringObject*));
pDvmDex->pResClasses = (struct ClassObject**)
calloc(classCount, sizeof(struct ClassObject*));
pDvmDex->pResMethods = (struct Method**)
calloc(methodCount, sizeof(struct Method*));
pDvmDex->pResFields = (struct Field**)
calloc(fieldCount, sizeof(struct Field*));
LOGV("+++ DEX %p: allocateAux %d+%d+%d+%d * 4 = %d bytes",
pDvmDex, stringCount, classCount, methodCount, fieldCount,
(stringCount + classCount + methodCount + fieldCount) * 4);
//这条语句其实也是一样的,也是给pDvmDex中的pInterfaceCache对象分配空间,以及赋值而已
pDvmDex->pInterfaceCache = dvmAllocAtomicCache(DEX_INTERFACE_CACHE_SIZE);
if (pDvmDex->pResStrings == NULL ||
pDvmDex->pResClasses == NULL ||
pDvmDex->pResMethods == NULL ||
pDvmDex->pResFields == NULL ||
pDvmDex->pInterfaceCache == NULL)
{
LOGE("Alloc failure in allocateAuxStructures");
free(pDvmDex->pResStrings);
free(pDvmDex->pResClasses);
free(pDvmDex->pResMethods);
free(pDvmDex->pResFields);
free(pDvmDex);
return NULL;
}
return pDvmDex;
}
看到这里,你觉得pDvmDex这个对象被喂饱了吗,有没有喂饱就决定了你到底对这个源代码理解了多少,我们继续向下:
刚才dexFileParse((u1*)addr, len, parseFlags) 剩下的部分是这些:显然可以明白是什么意思,不用多说了,那么我们要返回上上个函数没执行完多部分了,就是rewriteDex这个函数,if (pDvmDex == NULL) { dexFileFree(pDexFile); goto bail; } pDvmDex->isMappedReadOnly = false; *ppDvmDex = pDvmDex; result = 0; bail: return result; }
我们还是从这个函数的上次没执行玩的第一条语句开始吧:
//回顾下,pClassLoopup是DexClassLookup类型的对象,这个对象的主要是用来后续方便查找和加载类的,见下面详细分析dexCreateClassLookup这个函数:pClassLookup = dexCreateClassLookup(pDvmDex->pDexFile); if (pClassLookup == NULL) goto bail; pDvmDex->pDexFile->pClassLookup = pClassLookup; //这里的doVeriry和doOpt的值为false,所以可以直接跳进入bail了 if (!doVerify && !doOpt) { result = true; goto bail; } prepWhen = dvmGetRelativeTimeUsec(); if (!loadAllClasses(pDvmDex)) goto bail; loadWhen = dvmGetRelativeTimeUsec(); if (!dvmCreateInlineSubsTable()) goto bail; verifyAndOptimizeClasses(pDvmDex->pDexFile, doVerify, doOpt); verifyOptWhen = dvmGetRelativeTimeUsec(); if (doVerify && doOpt) msgStr = "verify+opt"; else if (doVerify) msgStr = "verify"; else if (doOpt) msgStr = "opt"; LOGD("DexOpt: load %dms, %s %dms", (int) (loadWhen - prepWhen) / 1000, msgStr, (int) (verifyOptWhen - loadWhen) / 1000); result = true; bail: if (pDvmDex != NULL) { pDvmDex->pDexFile->pClassLookup = NULL; } if (ppDvmDex == NULL || !result) { dvmDexFileFree(pDvmDex); } else { *ppDvmDex = pDvmDex; } if (ppClassLookup == NULL || !result) { free(pClassLookup); } else { *ppClassLookup = pClassLookup; } return result; } ************************************************************** DexClassLookup* dexCreateClassLookup(DexFile* pDexFile) { DexClassLookup* pLookup; int allocSize; int i, numEntries; int numProbes, totalProbes, maxProbes; numProbes = totalProbes = maxProbes = 0; assert(pDexFile != NULL); //dexRoundUpPower2的函数的作用是把一个整数向上转化为一个2的幂,比如:1就是1,2就是2,3就是4,5就是8等等...... numEntries = dexRoundUpPower2(pDexFile->pHeader->classDefsSize * 2); //整个LoopUp表的大小,这里可以看到,有多少类,就有多大的空间,当然numEnties已经在现有的类的个数上翻了一番 allocSize = offsetof(DexClassLookup, table) + numEntries * sizeof(pLookup->table[0]); //为pLookup这个对象分配存储空间 pLookup = (DexClassLookup*) calloc(1, allocSize); if (pLookup == NULL) return NULL; //给pLookup结构体内的字段size,numEntries赋值 pLookup->size = allocSize; pLookup->numEntries = numEntries; //下面开始给pLookup这个对象中的table字段赋值,循环的对把各个类的信息加入到pLookup这张表中 for (i = 0; i < (int)pDexFile->pHeader->classDefsSize; i++) { const DexClassDef* pClassDef; const char* pString; //通过索引i来获得一个DexClassDef类型的对象 pClassDef = dexGetClassDef(pDexFile, i); //通过类中的类型索引来找到类类型,返回一个字符串 pString = dexStringByTypeIdx(pDexFile, pClassDef->classIdx); //classLookupAdd函数就是把第一个类的加入到pLookup这张表中 //下面先来分析下这个函数,详细分析见下面 classLookupAdd(pDexFile, pLookup, (u1*)pString - pDexFile->baseAddr, (u1*)pClassDef - pDexFile->baseAddr, &numProbes); if (numProbes > maxProbes) maxProbes = numProbes; totalProbes += numProbes; } LOGV("Class lookup: classes=%d slots=%d (%d%% occ) alloc=%d" " total=%d max=%d", pDexFile->pHeader->classDefsSize, numEntries, (100 * pDexFile->pHeader->classDefsSize) / numEntries, allocSize, totalProbes, maxProbes); return pLookup; } //好了,到这里为止,dexCreateClassLookup这个函数建立pLookup这个表的过程算是结束了,我们开始回溯到上面的未完成的代码中 ************************************************************** //下面来解释classLoopupAdd这个函数 static void classLookupAdd(DexFile* pDexFile, DexClassLookup* pLookup, int stringOff, int classDefOff, int* pNumProbes) { //得到类描述的一个字符串,其实就是类的类型 const char* classDescriptor = (const char*) (pDexFile->baseAddr + stringOff); //得到类的一个类定义对象 const DexClassDef* pClassDef = (const DexClassDef*) (pDexFile->baseAddr + classDefOff); //将类描述符通过这个hash函数进行hash,返回一个无符号整形的hash值 u4 hash = classDescriptorHash(classDescriptor); int mask = pLookup->numEntries-1; int idx = hash & mask; int probes = 0; //这里就开始查找pLookup这张表中空余的位置,来放置上面得到的类的信息 while (pLookup->table[idx].classDescriptorOffset != 0) { idx = (idx + 1) & mask; probes++; } pLookup->table[idx].classDescriptorHash = hash; pLookup->table[idx].classDescriptorOffset = stringOff; pLookup->table[idx].classDefOffset = classDefOff; *pNumProbes = probes; } //好了,到这里为止,已经把第一个类的信息放在了pLookup这张表中了,我们继续返回上面的代码 *************************************************************** 来来回回地终于把内存加载字节码的功能分析完了,中间可能漏了很多东西没有细讲,还有一些可能讲的不对,还望大牛多多批评指教,第一次分析源码,写的有点乱, 请看不懂的朋友随时联系我,我一定会改正的; 希望通过这个过程能够帮助有需要的人,也是对我自己的一次磨练!谢谢 请各位大牛批评指教!!!
最后
以上就是怕孤单方盒为你收集整理的Dex动态加载的C语言部分的全部内容,希望文章能够帮你解决Dex动态加载的C语言部分所遇到的程序开发问题。
如果觉得靠谱客网站的内容还不错,欢迎将靠谱客网站推荐给程序员好友。
发表评论 取消回复