iOS 如何抓取线程的“方法调用栈”?
2020/5/29 23:56:42
本文主要是介绍iOS 如何抓取线程的“方法调用栈”?,对大家解决编程问题具有一定的参考价值,需要的程序猿们随着小编来一起学习吧!
级别:★★☆☆☆
标签:「方法调用栈」「抓取线程的“方法调用栈”」
作者: 647
审校: QiShare团队
场景:
在一些 “性能监控” 的工具中,在检测到App主线程卡顿的时候,可以通过子线程抓取当前时刻所有线程的方法调用堆栈(保存卡顿现场),并在合适的时机(WiFi环境&网络环境较好的时候)把堆栈信息上传到我们的服务端。服务端将堆栈信息过滤分析后,交给客户端做优化处理。 这样,就能较好的提高用户的体验,并及时发现线上环境下的问题。
同时,也可以及时发现问题,及时优化我们的代码质量和执行效率。
(一个比较好的开发循环)
那么,在App发生卡顿时候,我们该如何抓取方法调用栈呢?堆栈信息又是什么样的呢?
本文将通过一个具体的 demo
,阐述如何进行抓栈操作。
在此之前,首先要感谢我偶像bestswifter的博客:《获取任意线程调用栈的那些事》,对我有很大的启发与帮助。
接下来,进入我们今天的正题:
- 什么是调用栈?
- 如何抓取线程当前的调用栈?
- 如何符号化解析?
- 一些特殊的调用栈
- (补充)如何检测App卡顿?
一、什么是调用栈?
调用栈(
call stack
):
是计算机科学中存储有关正在运行的子程序的消息的栈。—— 维基百科
在我们程序运行中,通常存在一个函数调用另一个函数的情况。
例如,在某个线程中,调用了 func A
。在 func A
执行过程中,调用了 func B
。
那么,在计算机程序底层需要做哪些事呢?
- 转移控制 :暂停
func A
,并开始执行func B
,并在func B
执行完后,再回到func A
继续执行。 - 转移数据 :
func A
要能把参数传递给func B
,并且func B
如果有返回值的话,要把返回值还给func A
。 - 分配和释放内存 :在
func B
开始执行时,给需要用到局部变量分配内存。在func B
执行完后,释放这部分内存。
举个例子,
我声明了两个函数:foo
、bar
。
同时,在函数foo
中调用了函数bar
。
- (void)foo { [self bar]; } - (void)bar { NSLog(@"QiShare"); } 复制代码
在模拟器(x86
)下,会转换成如下汇编:
QiStackFrameLogger`-[ViewController foo]: 0x105a1f0d0 <+0>: pushq %rbp 0x105a1f0d1 <+1>: movq %rsp, %rbp 0x105a1f0d4 <+4>: subq $0x10, %rsp 0x105a1f0d8 <+8>: movq %rdi, -0x8(%rbp) 0x105a1f0dc <+12>: movq %rsi, -0x10(%rbp) 0x105a1f0e0 <+16>: movq -0x8(%rbp), %rax 0x105a1f0e4 <+20>: movq 0x64a5(%rip), %rsi ; "bar" 0x105a1f0eb <+27>: movq %rax, %rdi 0x105a1f0ee <+30>: callq *0x3f1c(%rip) ; (void *)0x00007fff50ad3400: objc_msgSend -> 0x105a1f0f4 <+36>: addq $0x10, %rsp 0x105a1f0f8 <+40>: popq %rbp 0x105a1f0f9 <+41>: retq QiStackFrameLogger`-[ViewController bar]: 0x105a1f100 <+0>: pushq %rbp 0x105a1f101 <+1>: movq %rsp, %rbp 0x105a1f104 <+4>: subq $0x10, %rsp 0x105a1f108 <+8>: leaq 0x3f61(%rip), %rax ; @"QiShare" 0x105a1f10f <+15>: movq %rdi, -0x8(%rbp) 0x105a1f113 <+19>: movq %rsi, -0x10(%rbp) -> 0x105a1f117 <+23>: movq %rax, %rdi 0x105a1f11a <+26>: movb $0x0, %al 0x105a1f11c <+28>: callq 0x105a20cd4 ; symbol stub for: NSLog 0x105a1f121 <+33>: jmp 0x105a1f121 ; <+33> at ViewController.m:24:5 复制代码
在我的真机(arm64
)下,会转换成如下汇编:
QiStackFrameLogger`-[ViewController foo]: 0x10443833c <+0>: sub sp, sp, #0x20 ; =0x20 0x104438340 <+4>: stp x29, x30, [sp, #0x10] 0x104438344 <+8>: add x29, sp, #0x10 ; =0x10 0x104438348 <+12>: adrp x8, 9 0x10443834c <+16>: add x8, x8, #0x5a8 ; =0x5a8 0x104438350 <+20>: str x0, [sp, #0x8] 0x104438354 <+24>: str x1, [sp] 0x104438358 <+28>: ldr x9, [sp, #0x8] 0x10443835c <+32>: ldr x1, [x8] 0x104438360 <+36>: mov x0, x9 0x104438364 <+40>: bl 0x10443a0ac ; symbol stub for: objc_msgSend -> 0x104438368 <+44>: ldp x29, x30, [sp, #0x10] 0x10443836c <+48>: add sp, sp, #0x20 ; =0x20 0x104438370 <+52>: ret QiStackFrameLogger`-[ViewController bar]: 0x104438374 <+0>: sub sp, sp, #0x20 ; =0x20 0x104438378 <+4>: stp x29, x30, [sp, #0x10] 0x10443837c <+8>: add x29, sp, #0x10 ; =0x10 0x104438380 <+12>: str x0, [sp, #0x8] 0x104438384 <+16>: str x1, [sp] -> 0x104438388 <+20>: adrp x0, 4 0x10443838c <+24>: add x0, x0, #0x58 ; =0x58 0x104438390 <+28>: bl 0x104439fe0 ; symbol stub for: NSLog 0x104438394 <+32>: b 0x104438394 ; <+32> at ViewController.m:24:5 复制代码
再转换成更直观的图解,就变成了这样:
目前,绝大部分iOS设备都是基于arm64
架构的(iPhone5s
及之后发布的所有设备)。
通过查询 arm的官方文档,我们可以得知:
地址 | 名称 | 作用 |
---|---|---|
sp | 栈指针(stack pointer) | 存放当前函数的地址。 |
x30 | 链接寄存器(link register) | 存储函数的返回地址。 |
x29 | 帧指针寄存器(frame pointer) | 上一级函数的地址(与x30一致)。 |
x19~x28 | Callee-saved registers | 被调用这保存寄存器。 |
x18 | The Platform Register | 平台保留,操作系统自身使用。 |
x17、x16 | Intra-procedure-call temporary registers | 临时寄存器。 |
x9~x15 | Temporary registers | 临时寄存器,用来保存本地变量。 |
x8 | Indirect result location register | 间接返回地址,返回地址过大时使用。 |
x0~x7 | Parameter/result registers | 参数/返回值寄存器。 |
其中,比较重要的是栈指针(stack pointer
,下面简称sp
)与帧指针(frame pointer
,下面简称fp
)。
sp
会存储当前函数的栈顶地址,fp
会存储上一级函数的sp
。
二、如何抓取线程当前的调用栈?
刚才,我们已经知道了通过fp
就能找到上一级函数的地址。
通过不停的找上一级fp
就能找到当前所有方法调用栈的地址。(回溯法)
Talk is easy, show me code.
- 第一步:
首先,我们声明一个结构体,用来存储链式的栈指针信息。(sp
+fp
)
// 栈帧结构体: typedef struct QiStackFrameEntry { const struct QiStackFrameEntry *const previouts; //!< 上一个栈帧 const uintptr_t return_address; //!< 当前栈帧的地址 } QiStackFrameEntry; 复制代码
没错,是个链表。
- 第二步:
取出thread
里的machine context
。
_STRUCT_MCONTEXT machineContext; // 先声明一个context,再从thread中取出context if(![self qi_fillThreadStateFrom:thread intoMachineContext:&machineContext]) { return [NSString stringWithFormat:@"Fail to get machineContext from thread: %u\n", thread]; } 复制代码
具体实现:
/*! @brief 将machineContext从thread中提取出来 @param thread 当前线程 @param machineContext 所要赋值的machineContext @return 是否获取成功 */ + (BOOL) qi_fillThreadStateFrom:(thread_t) thread intoMachineContext:(_STRUCT_MCONTEXT *)machineContext { mach_msg_type_number_t state_count = Qi_THREAD_STATE_COUNT; kern_return_t kr = thread_get_state(thread, Qi_THREAD_STATE, (thread_state_t)&machineContext->__ss, &state_count); return kr == KERN_SUCCESS; } 复制代码
- 第三步:
获取machineContext
里,在栈帧的指针地址。
再通过fp
的回溯,将所有的方法地址保存在backtraceBuffer
数组中。
直到找到最底层,没有上一级地址就break
。
uintptr_t backtraceBuffer[50]; int i = 0; NSMutableString *resultString = [[NSMutableString alloc] initWithFormat:@"Backtrace of Thread %u:\n", thread]; const uintptr_t instructionAddress = qi_mach_instructionAddress(&machineContext); backtraceBuffer[i++] = instructionAddress; uintptr_t linkRegister = qi_mach_linkRegister(&machineContext); if (linkRegister) { backtraceBuffer[i++] = linkRegister; } if (instructionAddress == 0) { return @"Fail to get instructionAddress."; } QiStackFrameEntry frame = {0}; const uintptr_t framePointer = qi_mach_framePointer(&machineContext); if (framePointer == 0 || qi_mach_copyMem((void *)framePointer, &frame, sizeof(frame)) != KERN_SUCCESS) { return @"Fail to get frame pointer"; } // 对frame进行赋值 for (; i<50; i++) { backtraceBuffer[i] = frame.return_address; // 把当前的地址保存 if (backtraceBuffer[i] == 0 || frame.previouts == 0 || qi_mach_copyMem(frame.previouts, &frame, sizeof(frame)) != KERN_SUCCESS) { break; // 找到原始帧,就break } } 复制代码
这样,backtraceBuffer
这个数组中,就存了当前时刻线程的方法调用地址(fp
的集合)
但backtraceBuffer
这个数组,目前只是一堆方法的地址。
我们并不知道它具体指的是哪个方法?
那就需要接下来的 “符号化解析” 操作。
将每个地址与对应符号名(函数/方法名)一一对应上。
三、如何符号化解析?
我们通过回溯帧指针(fp
),就能拿到线程下的所有函数调用地址。
我们怎么把地址与对应的符号(函数/方法名)对应上呢?
这就需要符号化解析步骤。
符号化解析:“地址” => “符号”。
- 预备:
这次不用我们自己声明了,系统帮我们准备好了结构体dl_info
。
专门用来存储当前的符号信息。
/* * Structure filled in by dladdr(). */ typedef struct dl_info { const char *dli_fname; /* Pathname of shared object */ void *dli_fbase; /* Base address of shared object */ const char *dli_sname; /* Name of nearest symbol */ void *dli_saddr; /* Address of nearest symbol */ } Dl_info; 复制代码
- 第一步:
根据backtraceBuffer
数组的大小,声明一个同样大小的dl_info[]
数组来存符号信息。
int backtraceLength = i; Dl_info symbolicated[backtraceLength]; qi_symbolicate(backtraceBuffer, symbolicated, backtraceLength, 0); //!< 符号化 复制代码
- 第二步:
通过address
找到符号所在的image
。
下面的方法,可以拿到对应image
的index
(编号)。
// 找出address所对应的image编号 uint32_t qi_getImageIndexContainingAddress(const uintptr_t address) { const uint32_t imageCount = _dyld_image_count(); // dyld中image的个数 const struct mach_header *header = 0; for (uint32_t i = 0; i < imageCount; i++) { header = _dyld_get_image_header(i); if (header != NULL) { // 在提供的address范围内,寻找segment command uintptr_t addressWSlide = address - (uintptr_t)_dyld_get_image_vmaddr_slide(i); //!< ASLR uintptr_t cmdPointer = qi_firstCmdAfterHeader(header); if (cmdPointer == 0) { continue; } for (uint32_t iCmd = 0; iCmd < header->ncmds; iCmd++) { const struct load_command *loadCmd = (struct load_command*)cmdPointer; if (loadCmd->cmd == LC_SEGMENT) { const struct segment_command *segCmd = (struct segment_command*)cmdPointer; if (addressWSlide >= segCmd->vmaddr && addressWSlide < segCmd->vmaddr + segCmd->vmsize) { // 命中! return i; } } else if (loadCmd->cmd == LC_SEGMENT_64) { const struct segment_command_64 *segCmd = (struct segment_command_64*)cmdPointer; if (addressWSlide >= segCmd->vmaddr && addressWSlide < segCmd->vmaddr + segCmd->vmsize) { // 命中! return i; } } cmdPointer += loadCmd->cmdsize; } } } return UINT_MAX; // 没找到就返回UINT_MAX } 复制代码
- 第三步:
我们拿到了address
所对应的image
的index
。
我们就可以通过一些系统方法与计算,得到header
、虚拟内存地址、ASLR偏移量(安全性考虑,为了防黑客入侵。iOS 5
、Android 4
后引入)。
以及,比较关键的segmentBase
(通过baseAddress
+ASLR
得到)。
const struct mach_header *header = _dyld_get_image_header(index); // 根据index找到header const uintptr_t imageVMAddrSlide = (uintptr_t)_dyld_get_image_vmaddr_slide(index); //image虚拟内存地址 const uintptr_t addressWithSlide = address - imageVMAddrSlide; // ASLR偏移量 const uintptr_t segmentBase = qi_getSegmentBaseAddressOfImageIndex(index) + imageVMAddrSlide; // segmentBase是根据index + ASLR得到的 if (segmentBase == 0) { return false; } info->dli_fname = _dyld_get_image_name(index); info->dli_fbase = (void *)header; 复制代码
- 第四步:
通过查找符号表,找到对应的符号,并赋值给dl_info
数组。
// 查找符号表,找到对应的符号 const Qi_NLIST* bestMatch = NULL; uintptr_t bestDistace = ULONG_MAX; uintptr_t cmdPointer = qi_firstCmdAfterHeader(header); if (cmdPointer == 0) { return false; } for (uint32_t iCmd = 0; iCmd < header->ncmds; iCmd++) { const struct load_command* loadCmd = (struct load_command*)cmdPointer; if (loadCmd->cmd == LC_SYMTAB) { const struct symtab_command *symtabCmd = (struct symtab_command*)cmdPointer; const Qi_NLIST* symbolTable = (Qi_NLIST*)(segmentBase + symtabCmd->symoff); const uintptr_t stringTable = segmentBase + symtabCmd->stroff; /* * struct symtab_command { uint32_t cmd; / LC_SYMTAB / uint32_t cmdsize; / sizeof(struct symtab_command) / uint32_t symoff; / symbol table offset 符号表偏移 / uint32_t nsyms; / number of symbol table entries 符号表条目的数量 / uint32_t stroff; / string table offset 字符串表偏移 / uint32_t strsize; / string table size in bytes 字符串表的大小(以字节为单位) / }; */ for (uint32_t iSym = 0; iSym < symtabCmd->nsyms; iSym++) { // 如果n_value为0,则该符号引用一个外部对象。 if (symbolTable[iSym].n_value != 0) { uintptr_t symbolBase = symbolTable[iSym].n_value; uintptr_t currentDistance = addressWithSlide - symbolBase; if ((addressWithSlide >= symbolBase) && (currentDistance <= bestDistace)) { bestMatch = symbolTable + iSym; bestDistace = currentDistance; } } } if (bestMatch != NULL) { info->dli_saddr = (void*)(bestMatch->n_value + imageVMAddrSlide); info->dli_sname = (char*)((intptr_t)stringTable + (intptr_t)bestMatch->n_un.n_strx); if (*info->dli_sname == '_') { info->dli_sname++; } //如果所有的符号都被删除,就会发生这种情况。 if (info->dli_saddr == info->dli_fbase && bestMatch->n_type == 3) { info->dli_sname = NULL; } break; } } cmdPointer += loadCmd->cmdsize; } 复制代码
- 第五步:
遍历backtraceBuffer
数组,并把符号信息赋值dl_info
数组。
// 符号化:将backtraceBuffer(地址数组)转成symbolsBuffer(符号数组)。 void qi_symbolicate(const uintptr_t* const backtraceBuffer, Dl_info* const symbolsBuffer, const int numEntries, const int skippedEntries) { int i = 0; if(!skippedEntries && i < numEntries) { qi_dladdr(backtraceBuffer[i], &symbolsBuffer[i]); i++; } for (; i < numEntries; i++) { qi_dladdr(CALL_INSTRUCTION_FROM_RETURN_ADDRESS(backtraceBuffer[i]), &symbolsBuffer[i]); //!< 通过回溯得到的栈帧,找到对应的符号名。 } } 复制代码
- 小结:
符号化解析,完整代码如下:
#pragma mark - Symbolicate // 符号化:将backtraceBuffer(地址数组)转成symbolsBuffer(符号数组)。 void qi_symbolicate(const uintptr_t* const backtraceBuffer, Dl_info* const symbolsBuffer, const int numEntries, const int skippedEntries) { int i = 0; if(!skippedEntries && i < numEntries) { qi_dladdr(backtraceBuffer[i], &symbolsBuffer[i]); i++; } for (; i < numEntries; i++) { qi_dladdr(CALL_INSTRUCTION_FROM_RETURN_ADDRESS(backtraceBuffer[i]), &symbolsBuffer[i]); //!< 通过回溯得到的栈帧,找到对应的符号名。 } } // 通过address得到当前函数info信息,包括:dli_fname、dli_fbase、dli_saddr、dli_sname. bool qi_dladdr(const uintptr_t address, Dl_info* const info) { info->dli_fname = NULL; info->dli_fbase = NULL; info->dli_saddr = NULL; info->dli_sname = NULL; const uint32_t index = qi_getImageIndexContainingAddress(address); // 根据地址找到image中的index。 if (index == UINT_MAX) { return false; // 没找到就返回UINT_MAX } /* Header ------------------ Load commands Segment command 1 -------------| Segment command 2 | ------------------ | Data | Section 1 data |segment 1 <----| Section 2 data | <----| Section 3 data | <----| Section 4 data |segment 2 Section 5 data | ... | Section n data | */ /*----------Mach Header---------*/ const struct mach_header *header = _dyld_get_image_header(index); // 根据index找到header const uintptr_t imageVMAddrSlide = (uintptr_t)_dyld_get_image_vmaddr_slide(index); //image虚拟内存地址 const uintptr_t addressWithSlide = address - imageVMAddrSlide; // ASLR偏移量 const uintptr_t segmentBase = qi_getSegmentBaseAddressOfImageIndex(index) + imageVMAddrSlide; // segmentBase是根据index + ASLR得到的 if (segmentBase == 0) { return false; } info->dli_fname = _dyld_get_image_name(index); info->dli_fbase = (void *)header; // 查找符号表,找到对应的符号 const Qi_NLIST* bestMatch = NULL; uintptr_t bestDistace = ULONG_MAX; uintptr_t cmdPointer = qi_firstCmdAfterHeader(header); if (cmdPointer == 0) { return false; } for (uint32_t iCmd = 0; iCmd < header->ncmds; iCmd++) { const struct load_command* loadCmd = (struct load_command*)cmdPointer; if (loadCmd->cmd == LC_SYMTAB) { const struct symtab_command *symtabCmd = (struct symtab_command*)cmdPointer; const Qi_NLIST* symbolTable = (Qi_NLIST*)(segmentBase + symtabCmd->symoff); const uintptr_t stringTable = segmentBase + symtabCmd->stroff; /* * struct symtab_command { uint32_t cmd; / LC_SYMTAB / uint32_t cmdsize; / sizeof(struct symtab_command) / uint32_t symoff; / symbol table offset 符号表偏移 / uint32_t nsyms; / number of symbol table entries 符号表条目的数量 / uint32_t stroff; / string table offset 字符串表偏移 / uint32_t strsize; / string table size in bytes 字符串表的大小(以字节为单位) / }; */ for (uint32_t iSym = 0; iSym < symtabCmd->nsyms; iSym++) { // 如果n_value为0,则该符号引用一个外部对象。 if (symbolTable[iSym].n_value != 0) { uintptr_t symbolBase = symbolTable[iSym].n_value; uintptr_t currentDistance = addressWithSlide - symbolBase; if ((addressWithSlide >= symbolBase) && (currentDistance <= bestDistace)) { bestMatch = symbolTable + iSym; bestDistace = currentDistance; } } } if (bestMatch != NULL) { info->dli_saddr = (void*)(bestMatch->n_value + imageVMAddrSlide); info->dli_sname = (char*)((intptr_t)stringTable + (intptr_t)bestMatch->n_un.n_strx); if (*info->dli_sname == '_') { info->dli_sname++; } //如果所有的符号都被删除,就会发生这种情况。 if (info->dli_saddr == info->dli_fbase && bestMatch->n_type == 3) { info->dli_sname = NULL; } break; } } cmdPointer += loadCmd->cmdsize; } return true; } 复制代码
四、一些特殊的调用栈
看似,我们的抓取方案和抓栈策略都无懈可击。
但在release
环境中,由于编译器帮我们做了优化,有一些特殊的调用栈是抓不到的。
1. 尾调用优化
尾调用优化的本质,是 “栈帧” 的复用。
因此,每次压栈都会复用原来的栈帧。
这时候,我们抓到的堆栈永远只有最下层的栈,而中间的调用栈全都丢失了。
PS:关于尾调用优化,我之前实习的时候写了一篇博客。
可供参考:《iOS objc_msgSend尾调用优化详解》
2. 函数内联
这个也比较好理解,因为内联函数会在编译时期展开。
直接复制代码块,从而节省了调用函数带来的额外时间开支。
并且,有的编译器会自动帮我们把一些逻辑简单的函数优化为内联函数。
因此,被编译器优化成内联函数的函数,我们也是没有办法抓到调用栈的。
补:关于如何检测App卡顿?
可参考我之前写的博客:《iOS 性能监控(二)—— 主线程卡顿监控》。
我们能感知到的App卡顿,是由于主线程出现卡顿,造成UI更新不及时,从而发生丢帧等情况。(正常情况下,iPhone的屏幕都是60fps
,即一秒刷新60次。)
那么,目前比较好的监控方案就是利用runloop
原理去监控App状态,
方案如下:
-
第一步:开启一个子线程,并打开子线程的
runloop
,让该子线程常驻在App
中。 -
第二步:创建一个
RunloopObserver
(Runloop
观察者),将RunloopObserver
添加到主线程runloop
的commonModes
下观察。同时,子线程的runloop
开始监听。 -
第三步:每当主线程
runloop
的状态发生变化时,就会通知该RunloopObserver
。并通过发GCD信号量保证同步操作。同时,子线程的runloop
持续监听。 -
第四步:当主线程的
runloop
的状态长时间卡在BeforeSources
、AfterWaiting
时,就代表当前主线程卡顿。 -
第五步:检测到卡顿,抓栈,保留现场。 同时,将调用栈信息保存在本地,在合适的时机上报服务端。
Q1:为什么是主线程的
CommonModes
?
主线程的runloop有DefaultMode
、UITrackingMode
、UIInitializationMode
、GSEventReceiveMode
、CommonModes
。
其中,CommonModes
是DefaultMode
、UITrackingMode
的集合。
正常情况,也是在这两个mode
下切换。
Q2:为什么是
BeforeSources
、AfterWaiting
这两个状态?
这就要说到runloop
的执行顺序,
BeforeSources
之后,主要是处理Source0
事件(响应UIEvent
)。如果卡在这个状态过久,说明当前App无法响应点击事件。
AfterWaiting
之后,说明当前线程刚从休眠中唤醒,准备执行timer
事件。但又卡在这个状态,没有去执行。也能说明当前App卡顿。
PS:更详细监控方案过程,可查看我之前写的博客。
可供参考:《iOS 性能监控(二)—— 主线程卡顿监控》。
源码:
GitHub地址:QiStackFrameLogger
参考与致谢:
1.《获取任意线程调用栈的那些事》—— bestswifter
2.《iOS开发高手课》—— 戴铭老师
3.《调用栈》—— 维基百科
4.《Call Stack(调用栈)是什么?》—— 知乎
5.《Virtual Memory(虚拟内存)是什么?》
6.《arm64官方文档》
了解更多iOS及相关新技术,请关注我们的公众号:
关注我们的途径有:
QiShare(简书)
QiShare(掘金)
QiShare(知乎)
QiShare(GitHub)
QiShare(CocoaChina)
QiShare(StackOverflow)
QiShare(微信公众号)
推荐文章:
浅谈编译过程
深入理解HTTPS 浅谈 GPU 及 “App渲染流程”
iOS 查看及导出项目运行日志
Flutter Platform Channel 使用与源码分析
开发没切图怎么办?矢量图标(iconFont)上手指南
DarkMode、WKWebView、苹果登录是否必须适配?
奇舞团安卓团队——aTaller
奇舞周刊
这篇关于iOS 如何抓取线程的“方法调用栈”?的文章就介绍到这儿,希望我们推荐的文章对大家有所帮助,也希望大家多多支持为之网!
- 2022-10-05Swift语法学习--基于协议进行网络请求
- 2022-08-17Apple开发_Swift语言地标注释
- 2022-07-24Swift 初见
- 2022-05-22SwiftUI App 支持多语种 All In One
- 2022-05-10SwiftUI 组件参数简写 All In One
- 2022-04-14SwiftUI 学习笔记
- 2022-02-23Swift 文件夹和文件操作
- 2022-02-17Swift中使用KVO
- 2022-02-08Swift 汇编 String array
- 2022-01-30SwiftUI3.0页面反向传值