阅读 778

iOS异常浅析

异常简介

处理器和系统内核中有设计标识不同事件的状态码,这些状态被编码为不同的位和信号。每次处理器和内核检测到状态的变化时,便会触发一个事件,该事件称为异常

系统中可能的每种类型的异常都分配了一个唯一的非负整数的异常号。这些异常号由处理器和操作系统的内核设计者分配。

当处理器检测到事件时,会通过一张被称为异常表的跳转表,进行间接过程调用到一个专门设计用来处理这类事件的操作系统的子程序,称为异常处理程序

在系统启动时(计算机启动),操作系统会分配和初始化一张异常表,该表的起始地址存放在一个称为异常表基寄存器exception table base register)的特殊CPU寄存器中。异常号是异常表中的索引。通过起始地址与异常号,找到异常程序的调用地址,最终执行异常程序。

异常的类别

异常一般分四类:中断(interrupt),陷阱(trap),故障(fault),终止(abort)。

image.png

中断:是来自处理器外部的I/O异常信号导致的,不是由任何一条专门的指令造成的,从这个层面上来讲,它是异步的。硬件中断的处理程序通常被称为中断处理程序。比如:拔插U盘。

陷阱:是有意的异常,是执行一条指令的结果。和中断一样,陷阱处理程序将控制返回到下一条指令。陷阱最终的用途,是在用户程序和内核之间提供一个像过程一样的接口,称为系统调用。比如用户程序经常需要向内核请求服务,比如读一个文件(read)、创建一个进程(fork)、加载一个新的程序(execve)或者终止当前进程(exit),这些操作都需要通过触发陷阱异常,来执行系统内核的程序来实现。

故障:是由错误情况引起的,它可能被故障处理程序修正。当故障发生时,处理器将控制转移给故障处理程序。如果故障处理程序可以修正这个错误情况,便会将控制放回到故障指令,从而重新执行它,如果不能修正,故障处理程序便会将控制转移到系统内核的abort()函数,abort()最终会终止引起故障的应用程序。

一般保护性故障,Unix不会尝试恢复,而是将这种保护性故障报告为段违规(segmentation violation)对应信号为SIGSEGV,然后终止程序。比如:除零、程序尝试写入只读的文本段等。

故障的经典示例便是缺页异常。 终止:是由不可恢复的致命错误造成的结果。终止从不将控制返回给应用程序。如:奇偶校验错误(机器硬件错误检测)

Unix信号(signal)

更高层的软件形式的异常,一个信号就是一条消息,它通知进程某种类型的消息已经在系统中发生了。信号提供了一种向用户进程通知这些异常发生的机制,也允许进程中断其他进程。

LinuxmacOS都是类Unix系统,下表列举了Linux系统支持的30多种不同类型的信号,有许多Linux信号在macOS同样适用:

号码名字默认行为相应事件号码名字默认行为相应事件
1SIGHUP终止终端线挂起16SIGSTKFLT终止协处理器上的栈故障
2SIGINT终止来自键盘的中断17SIGCHLD忽略一个子进程暂停或者终止
3SIGQUIT终止来自键盘的退出18SIGCONT忽略若进程暂停则继续进程
4SIGILL终止非法指令19SIGSTOP停止直到下一个SIGCONT不来自终端的暂停信号
5SIGTRAP终止并转储存储器跟踪陷阱20SIGTSTP停止直到下一个SIGCONT来自终端的暂停信号
6SIGABRT终止并转储存储器来自abort函数的终止信号21SIGTTIN停止直到下一个SIGCONT后台进程从终端读
7SIGBUS终止总线错误22SIGTTOU停止直到下一个SIGCONT后台进程向终端写
8SIGFPE终止并转储存储器浮点异常23SIGURG忽略套接字上的紧急情况
9SIGKILL终止杀死程序24SIGXCPU终止CPU时间超出限制
10SIGUSER1终止用户定义的信号125SIGXFSZ终止文件大小超出限制
11SIGSEGV终止无效的存储器引用(段故障)26SIGVTALRM终止虚拟定时器期满
12SIGUSER2终止用户定义的信号227SIGPROF终止剖析定时器期满
13SIGPIPE终止向一个没有用户读的管道做写操作28SIGWINCH忽略窗口大小变化
14SIGALRM终止来自alarm函数的定时器信号29SIGIO终止在某个描述符上执行I/O操作
15SIGTERM终止软件终止信号30SIGPWR终止电源故障

系统内核

Mac OS X&iOS&iPad OS系统内核都是DarwinDarwin包含了开放源代码的XNU混合内核,它包含了Mach/BSDBSD是建立在Mach之上提供标准化(POSIX)的APIXNU的核心是Mach。下图为OS X 内核架构,查看来源

image.png

Mach:是一个微内核的操作系统。微内核仅处理最核心的任务,其他任务交给用户态的程序,包括文件管理,设备驱动等服务,这些服务被分解到不同的地址空间,彼此消息传递需要IPC。主要负责:线程与进程管理、虚拟内存管理、进程通信与消息传递、任务调度。与之对应的单内核则是把所有的服务放在相同的地址空间下,服务之间可相互调用。

BSD:是Unix的衍生系统。主要负责:Unix进程模型、POSIX线程模型以及相关原语、文件系统访问、设备访问、网络协议栈、Unix用户与群组。

异常来源

iOS中异常主要来源于硬件异常、软件异常、Mach异常、Signal异常,它们之间的关系如下图:

image.png

Mach异常

Mach异常是系统内核级异常,是由CPU触发一个陷阱引发,调用到Mach的异常处理程序,将来自硬件的异常转换为Mach异常,然后将Mach异常传递到相应的threadtaskhost,若无结果返回,任务便会被终止。

Mach异常传递涉及到的内核函数如下图:

image.png 依据上图提供信息,查阅苹果开源资料,找到对应的函数信息,简单列举如下(详细查阅请前往此处):

struct ppc_saved_state *trap(int trapno,
                 struct ppc_saved_state *ssp,
                 unsigned int dsisr,
                 unsigned int dar) {
      //... 
      doexception(exception, code, subcode);
      //...
}
复制代码
void doexception(
        int exc,
        int code,
        int sub) {
    exception_data_type_t   codes[EXCEPTION_CODE_MAX];

    codes[0] = code;    
    codes[1] = sub;
    exception(exc, codes, 2);
}
复制代码
// Des:The current thread caught an exception.
// We make an up-call to the thread's exception server.
void exception(
    exception_type_t    exception,
    exception_data_t    code,
    mach_msg_type_number_t  codeCnt)
{
    thread_act_t        thr_act;
    task_t            task;
    host_priv_t        host_priv;
    struct exception_action *excp;
    mutex_t            *mutex;

    assert(exception != EXC_RPC_ALERT);
    if (exception == KERN_SUCCESS)
        panic("exception");
    /*
     * Try to raise the exception at the activation level.线程级别
     */
    thr_act = current_act();
    mutex = mutex_addr(thr_act->lock);
    excp = &thr_act->exc_actions[exception];
    exception_deliver(exception, code, codeCnt, excp, mutex);
    /*
     * Maybe the task level will handle it. 任务级别
     */
    task = current_task();
    mutex = mutex_addr(task->lock);
    excp = &task->exc_actions[exception];
    exception_deliver(exception, code, codeCnt, excp, mutex);
    /*
     * How about at the host level? 主机级别
     */
    host_priv = host_priv_self();
    mutex = mutex_addr(host_priv->lock);
    excp = &host_priv->exc_actions[exception];
    exception_deliver(exception, code, codeCnt, excp, mutex);
    /*
     * Nobody handled it, terminate the task. 没有处理终止
     */
         // ...
    (void) task_terminate(task);
    thread_exception_return();
    /*NOTREACHED*/
}
复制代码
// Make an upcall to the exception server provided.
void exception_deliver(
    exception_type_t    exception,
    exception_data_t    code,
    mach_msg_type_number_t  codeCnt,
    struct exception_action *excp,
    mutex_t            *mutex)
{
        ///...
    
    int behavior = excp->behavior;

    switch (behavior) {
    case EXCEPTION_STATE: {
        ///EXCEPTION_STATE:Send a `catch_exception_raise_state` message 
        ///including the thread state.
        //..
        kr = exception_raise_state(exc_port, exception,
                           code, codeCnt,
                           &flavor,
                           state, state_cnt,
                           state, &state_cnt);
                //..                                
        return;
    }

    case EXCEPTION_DEFAULT:
        ///EXCEPTION_DEFAULT表示:Send a `catch_exception_raise` message 
        ///including the thread identity.
        //..
        kr = exception_raise(exc_port,
                retrieve_act_self_fast(a_self),
                retrieve_task_self_fast(a_self->task),
                exception,
                code, codeCnt);
                //..                
        return;

    case EXCEPTION_STATE_IDENTITY: {
        /// EXCEPTION_STATE_IDENTITY:表示Send a `catch_exception_raise_state_identity` message 
        ///including the thread identity and state.
        //..
            kr = exception_raise_state_identity(exc_port,
                retrieve_act_self_fast(a_self),
                retrieve_task_self_fast(a_self->task),
                exception,
                code, codeCnt,
                &flavor,
                state, state_cnt,
                state, &state_cnt);
         //..
         return;
    }
    
    default:
        panic ("bad exception behavior!");
    }
}
复制代码

关于如何捕获Mach异常,苹果文档描述很少,也没有提供可用的API,具体的Mach内核的API介绍,可从此处查阅。

Sigal信号

BSD派生自Unix操作系统,属于类Unix系统,基于Mach内核进程任务,提供POSIX应用程序接口。详见维基百科-XNU。基于此,Unix Signal机制同样适用苹果操作系统。苹果系统对于Unix Signal的定义,可通过#import <sys/signal.h>跳转查看。

Mach异常-> Signal信号

苹果操作系统Mach异常与Signal信号共存。Mach将操作系统的核心部分当做独立进程运行,与BSD服务进程之间通过IPC机制实现消息传递。同理Mach内核态的异常也是基于IPC将异常消息发送到BSDBSD将消息转换为用户态的Signal信号。具体流程如下:

  1. 苹果内核启动时会执行bsdinit_task()并最终调用ux_handler_init()方法。
void bsdinit_task(void)
{
    proc_t p = current_proc();
    struct uthread *ut;
    thread_t thread;

    process_name("init", p);

    ux_handler_init();

    thread = current_thread();
    (void) host_set_exception_ports(host_priv_self(),
                    EXC_MASK_ALL & ~(EXC_MASK_RPC_ALERT),//pilotfish (shark) needs this port
                    (mach_port_t) ux_exception_port,
                    EXCEPTION_DEFAULT| MACH_EXCEPTION_CODES,
                    0);

    ut = (uthread_t)get_bsdthread_info(thread);

    bsd_init_task = get_threadtask(thread);
    init_task_failure_data[0] = 0;

#if CONFIG_MACF
    mac_cred_label_associate_user(p->p_ucred);
    mac_task_label_update_cred (p->p_ucred, (struct task *) p->task);
#endif
    load_init_program(p);
    lock_trace = 1;
}
复制代码
  1. ux_handler_init()初始化一个ux_handler()方法,并创建线程执行它。
void ux_handler_init(void)
{
    thread_t thread = THREAD_NULL;

    ux_exception_port = MACH_PORT_NULL;
    (void) kernel_thread_start((thread_continue_t)ux_handler, NULL, &thread);
    thread_deallocate(thread);
    proc_list_lock();
    if (ux_exception_port == MACH_PORT_NULL)  {
        (void)msleep(&ux_exception_port, proc_list_mlock, 0, "ux_handler_wait", 0);
    }
    proc_list_unlock();
}
复制代码
  1. ux_handler()申请用于接收Mach内核消息的端口(port)集合,接收来自Mach的异常消息
static void ux_handler(void)
{
    task_t        self = current_task();
    mach_port_name_t    exc_port_name;
    mach_port_name_t    exc_set_name;

    /*
     *    Allocate a port set that we will receive on.
     */
    if (mach_port_allocate(get_task_ipcspace(ux_handler_self), MACH_PORT_RIGHT_PORT_SET,  &exc_set_name) != MACH_MSG_SUCCESS)
        panic("ux_handler: port_set_allocate failed");

    /*
     *    Allocate an exception port and use object_copyin to
     *    translate it to the global name.  Put it into the set.
     */
    if (mach_port_allocate(get_task_ipcspace(ux_handler_self), MACH_PORT_RIGHT_RECEIVE, &exc_port_name) != MACH_MSG_SUCCESS)
    panic("ux_handler: port_allocate failed");
    if (mach_port_move_member(get_task_ipcspace(ux_handler_self),
                exc_port_name,  exc_set_name) != MACH_MSG_SUCCESS)
    panic("ux_handler: port_set_add failed");

    if (ipc_object_copyin(get_task_ipcspace(self), exc_port_name,
            MACH_MSG_TYPE_MAKE_SEND, 
            (void *) &ux_exception_port) != MACH_MSG_SUCCESS)
        panic("ux_handler: object_copyin(ux_exception_port) failed");

    proc_list_lock();
    thread_wakeup(&ux_exception_port);
    proc_list_unlock();

    /* Message handling loop. */

    for (;;) {
    struct rep_msg {
        mach_msg_header_t Head;
        NDR_record_t NDR;
        kern_return_t RetCode;
    } rep_msg;
    struct exc_msg {
        mach_msg_header_t Head;
        /* start of the kernel processed data */
        mach_msg_body_t msgh_body;
        mach_msg_port_descriptor_t thread;
        mach_msg_port_descriptor_t task;
        /* end of the kernel processed data */
        NDR_record_t NDR;
        exception_type_t exception;
        mach_msg_type_number_t codeCnt;
        mach_exception_data_t code;
        /* some times RCV_TO_LARGE probs */
        char pad[512];
    } exc_msg;
    mach_port_name_t    reply_port;
    kern_return_t     result;

    exc_msg.Head.msgh_local_port = CAST_MACH_NAME_TO_PORT(exc_set_name);
    exc_msg.Head.msgh_size = sizeof (exc_msg);
#if 0
    result = mach_msg_receive(&exc_msg.Head);
#else
    result = mach_msg_receive(&exc_msg.Head, MACH_RCV_MSG,
                 sizeof (exc_msg), exc_set_name,
                 MACH_MSG_TIMEOUT_NONE, MACH_PORT_NULL,
                 0);
#endif
    if (result == MACH_MSG_SUCCESS) {
        reply_port = CAST_MACH_PORT_TO_NAME(exc_msg.Head.msgh_remote_port);
            ///收到消息后调用 mach_exc_server() 
        if (mach_exc_server(&exc_msg.Head, &rep_msg.Head)) {
                ///收到消息,回复消息
        result = mach_msg_send(&rep_msg.Head, MACH_SEND_MSG,
            sizeof (rep_msg),MACH_MSG_TIMEOUT_NONE,MACH_PORT_NULL);
        if (reply_port != 0 && result != MACH_MSG_SUCCESS)
            mach_port_deallocate(get_task_ipcspace(ux_handler_self), reply_port);
        }

    }
    else if (result == MACH_RCV_TOO_LARGE)
        /* ignore oversized messages */;
    else
        panic("exception_handler");
    }
}
复制代码
  1. ux_handler(void)收到Mach内核消息后,便会调用mach_exc_server函数,这个函数会根据异常的行为调用对应的catch_mach_exception_raise()catch_mach_exception_raise_state(), 和 catch_mach_exception_raise_state_identity()catch_mach_exception_raise()会触发Mach异常消息到Unix信号的转换。而关于mach_exc_server()的实现,并未像其他函数直接给出,具体请见此处

  2. 调用catch_mach_exception_raise()Mach异常转换为Unix信号,最终发送到对应线程。

kern_return_t catch_mach_exception_raise(
        __unused mach_port_t exception_port,
        mach_port_t thread,
        mach_port_t task,
        exception_type_t exception,
        mach_exception_data_t code,
        __unused mach_msg_type_number_t codeCnt
)
{
    ///...

    /*
     * Convert exception to unix signal and code.
     */
    ux_exception(exception, code[0], code[1], &ux_signal, &ucode);
        ///struct uthread *ut
        ///struct proc    *p;
        ut = get_bsdthread_info(th_act);
    p = proc_findthread(th_act);
        ///...
         /*
      * Send signal.
      */
      if (ux_signal != 0) {
        ut->uu_exception = exception;
        //ut->uu_code = code[0]; // filled in by threadsignal
        ut->uu_subcode = code[1];            
        threadsignal(th_act, ux_signal, code[0]);
       }
     if (p != NULL) 
       proc_rele(p);
       thread_deallocate(th_act);
    ///...
}
复制代码
static void ux_exception(
        int            exception,
        mach_exception_code_t     code,
        mach_exception_subcode_t subcode,
        int            *ux_signal,
        mach_exception_code_t     *ux_code)
{
    /*
     *    Try machine-dependent translation first.
     */
    if (machine_exception(exception, code, subcode, ux_signal, ux_code))
    return;
    
    switch(exception) {

    case EXC_BAD_ACCESS:
        if (code == KERN_INVALID_ADDRESS)
            *ux_signal = SIGSEGV;
        else
            *ux_signal = SIGBUS;
        break;

    case EXC_BAD_INSTRUCTION:
        *ux_signal = SIGILL;
        break;

    case EXC_ARITHMETIC:
        *ux_signal = SIGFPE;
        break;

    case EXC_EMULATION:
        *ux_signal = SIGEMT;
        break;

    case EXC_SOFTWARE:
        switch (code) {

        case EXC_UNIX_BAD_SYSCALL:
        *ux_signal = SIGSYS;
        break;
        case EXC_UNIX_BAD_PIPE:
        *ux_signal = SIGPIPE;
        break;
        case EXC_UNIX_ABORT:
        *ux_signal = SIGABRT;
        break;
        case EXC_SOFT_SIGNAL:
        *ux_signal = SIGKILL;
        break;
        }
        break;

    case EXC_BREAKPOINT:
        *ux_signal = SIGTRAP;
        break;
    }
}
复制代码

ux_exception()函数,展示了Mach异常与Signal信号转换关系。关于iOSMach异常信号的定义,可通过#include <mach/exception_types.h>跳转查看。

硬件异常

硬件异常依据前文所述,主要为:中断、缺陷、故障、终止。硬件异常的触发流程如下图: image.png

软件异常

应用级别的异常,在iOS中就是NSException。如果NSException异常没有捕获处理(try-catch),系统最终会调用abort()函数,向应用程序发送SIGABRT的信号。

void abort() {
         ///...
    /* <rdar://problem/7397932> abort() should call pthread_kill to deliver a signal to the aborting thread 
     * This helps gdb focus on the thread calling abort()
     */
    if (__is_threaded) {
        //...
        (void)pthread_kill(pthread_self(), SIGABRT);
    } else {
        //...
        (void)kill(getpid(), SIGABRT);
    }
    //...
}
复制代码

异常捕获

上文分析可知道硬件异常与软件异常最终都会转换为Unix Signal,因此对于Signal信号的处理,可以覆盖大部分的崩溃信息。除此之外系统给我们提供的NSException,可以用来获取更详细的奔溃信息。基于此,下文我们将只对SignalNSException的捕获进行简单示例。

Signal捕获

在进行Signal捕获时,需要注意覆盖问题。因为每个Signal对应一个Handler的处理函数,当我们通过绑定我们自己的Hanlder来收集奔溃信息时,可能会覆盖其他三方库已经绑定的Handler导致他们无法收集奔溃信息。

核心代码如下:

//头文件
#import <sys/signal.h>
#import "execinfo.h"
///1.用以保存旧的handler
static struct sigaction *previous_signalHandlers = NULL;
///2.定义我们要处理的信号
static int signals[] = {SIGABRT,SIGBUS,SIGFPE,SIGILL,SIGPIPE,SIGSEGV,SIGSYS,SIGTRAP};
///3.注册`Handler`
+ (BOOL)registerSignalHandler; {
    ///初始化我们的Sigaction
    struct sigaction action = { 0 };
    ///初始化存放旧的Sigaction数组
    int count = sizeof(signals) / sizeof(int);
    if (previous_signalHandlers == NULL) {
        previous_signalHandlers = malloc(sizeof(struct sigaction) * count);
    }
    action.sa_flags = SA_SIGINFO;
    sigemptyset(&action.sa_mask);
    /// 绑定我们的处理函数
    action.sa_sigaction = &_handleSignal;
    for (int i = 0; i < count; i ++) {
        ///遍历信号
        int signal = signals[i];///or  *(signals + i)
        ///绑定新的`Sigaction`,存储旧的`Sigaction`
        int result = sigaction(signal, &action, &previous_signalHandlers[i]);
        /// 绑定失败
        if (result != 0) {
            NSLog(@"signal:%d,error:%s",signal,strerror(errno));
            for (int j =i--; j >= 0;j--) {
                /// 恢复旧的Sigaction,此次函数返回NO
                sigaction(signals[j], &previous_signalHandlers[j], NULL);
            }
            return NO;
        }
    }
    return YES;
}
/// 4. 信号处理函数
void _handleSignal(int sigNum,siginfo_t *info,void *ucontext_t) {
    
    /// todo our operation
    NSLog(@"❌拦截到崩溃信号:%d,打印堆栈信息:%@",[CrashSignals callStackSymbols]);
    /// 获取`sigNum`在信号数组中对应的`index`
    int index = -1,count = sizeof(signals) / sizeof(int);
    for (int i = 0; i < count; i++) {
        if (*(signals + i) == sigNum) {
            index = i;
            break;
        }
    }
    if (index == -1) return;
    /// 取出旧的`Sigaction`
    struct sigaction previous_action = previous_signalHandlers[index];
    if (previous_action.sa_handler == SIG_IGN) {
        //`SIG_IGN`忽略信号,`SIG_DFL`默认方式处理信号
        return;
    }
    /// 恢复旧的`Sigaction`与Signal的绑定关系
    sigaction(sigNum, &previous_action, NULL);
    /// 重新抛出这个`Signal`,此时便会被`previous_action`的处理程序拦截到。
    raise(sigNum);
}
//5. 函数的调用栈
+ (NSArray*)callStackSymbols {
    /// ` int backtrace(void ** buffer , int size )`
    /// void ** buffer:在`buffer`指向的数组返回程序栈桢的回溯,
    /// void ** buffer: Each item in the array pointed to by buffer is of type void *
    void* backtrace_buffer[128];
    /// 返回值可能比 128大,大便截断,小则全部显示
    int numberOfReturnAdderss = backtrace(backtrace_buffer, 128);
    ///char **backtrace_symbols(void *const *buffer, int size);
    /// `backtrace_symbols()` translates the addresses into an array of strings that describe the addresses symbolically
    /// The size argument specifies the number of addresses in buffer
    char **symbols = backtrace_symbols(backtrace_buffer, numberOfReturnAdderss);
    /// 提取每个返回地址对应的符号信息,栈桢是嵌套的
    NSMutableArray *tempArray = [[NSMutableArray alloc]initWithCapacity:numberOfReturnAdderss];
    for (int i = 0 ; i < numberOfReturnAdderss; i++) {
        char *cstr_item = symbols[i];
        NSString *objc_str = [NSString stringWithUTF8String:cstr_item];
        [tempArray addObject:objc_str];
    }
    return [tempArray copy];
}
复制代码

NSException捕获

系统提供了对应的处理iOS系统中未被捕获的NSExceptionAPI,我们只需要按照API进行操作即可,但与Signal一样,需要注意多处注册的覆盖问题,避免影响项目中其他收集程序。

核心代码如下:

///1.声明用以保存旧的`Hanlder`的静态变量
static NSUncaughtExceptionHandler *previous_uncaughtExceptionHandler;
///注册处理应用级异常的`handler`
+ (void)registerExceptionHandler; {
    previous_uncaughtExceptionHandler = NSGetUncaughtExceptionHandler();
    NSSetUncaughtExceptionHandler(&_handleException);
}
///我们的异常处理程序
void _handleException(NSException *exception) {
    /// Todo our operation
    NSLog(@"✅拦截到异常的堆栈信息:%@",exception.callStackSymbols);
    
    /// 传递异常
    if (previous_uncaughtExceptionHandler != NULL) {
        previous_uncaughtExceptionHandler(exception);
    }
    // 杀掉程序,这样可以防止同时抛出的SIGABRT被Signal异常捕获
    /// kill (cannot be caught or ignored)
    kill(getpid(), SIGKILL);
}
复制代码

调试验证

XcodeDebug环境下,Signal异常与NSException异常都会被Xcode调试器拦截,不会走到我们的处理程序。因此代码的调试验证,笔者采用模拟器运行程序后,停止运行,脱离Xcode的调试环境,重新在模拟器打开程序,开启Mac的控制台程序,点击按钮触发奔溃,查看控制台对应模拟器的log记录,来验证是否正确捕获。另:Signal奔溃采用kill(getpid(), SIGBUS);来触发。

参考资料

flylib.com/books/en/3.…

shevakuilin.com/ios-crashpr…

minosjy.com/2021/04/10/…

developer.apple.com/library/arc…

文章分类
iOS
文章标签