iOS Crash

iOS Crash

Crash简介

Crash分为两种:

系统异常

系统异常是由EXC_BAD_ACCESS引起的,原因是访问了不属于本进程的内存地址,有可能是访问已被释放的内存;

iOS系统自带的 Apple’s Crash Reporter 记录在设备中的Crash日志,Exception Type项通常会包含两个元素: Mach异常 和 Unix信号。

Exception Type:         EXC_BAD_ACCESS (SIGSEGV)    
Exception Subtype:      KERN_INVALID_ADDRESS at 0x041a6f3
复制代码

Mach异常是什么?它又是如何与Unix信号建立联系的? Mach是一个XNU的微内核核心,Mach异常是指最底层的内核级异常,被定义在下 。每个thread,task,host都有一个异常端口数组,Mach的部分API暴露给了用户态,用户态的开发者可以直接通过Mach API设置thread,task,host的异常端口,来捕获Mach异常,抓取Crash事件。 所有Mach异常都在host层被ux_exception转换为相应的Unix信号,并通过threadsignal将信号投递到出错的线程。iOS中的 POSIX API 就是通过 Mach 之上的 BSD 层实现的。

Objective-C异常

Objective-C异常是未被捕获的Objective-C的NSException,导致程序向自身发送了SIGABRT信号而崩溃。对于未捕获的Objective-C异常,我们是有办法将它记录下来的。

无论哪种异常系统都会传出信号 下面是一些信号说明

  1. SIGHUP

本信号在用户终端连接(正常或非正常)结束时发出, 通常是在终端的控制进程结束时, 通知同一session内的各个作业, 这时它们与控制终端不再关联。 登录Linux时,系统会分配给登录用户一个终端(Session)。在这个终端运行的所有程序,包括前台进程组和后台进程组,一般都属于这个 Session。当用户退出Linux登录时,前台进程组和后台有对终端输出的进程将会收到SIGHUP信号。这个信号的默认操作为终止进程,因此前台进 程组和后台有终端输出的进程就会中止。不过可以捕获这个信号,比如wget能捕获SIGHUP信号,并忽略它,这样就算退出了Linux登录, wget也 能继续下载。 此外,对于与终端脱离关系的守护进程,这个信号用于通知它重新读取配置文件。

  1. SIGINT

程序终止(interrupt)信号, 在用户键入INTR字符(通常是Ctrl-C)时发出,用于通知前台进程组终止进程。

  1. SIGQUIT

和SIGINT类似, 但由QUIT字符(通常是Ctrl-)来控制. 进程在因收到SIGQUIT退出时会产生core文件, 在这个意义上类似于一个程序错误信号。

  1. SIGILL

执行了非法指令. 通常是因为可执行文件本身出现错误, 或者试图执行数据段. 堆栈溢出时也有可能产生这个信号。

  1. SIGTRAP

由断点指令或其它trap指令产生. 由debugger使用。

  1. SIGABRT 调用abort函数生成的信号。

  2. SIGBUS

非法地址, 包括内存地址对齐(alignment)出错。比如访问一个四个字长的整数, 但其地址不是4的倍数。它与SIGSEGV的区别在于后者是由于对合法存储地址的非法访问触发的(如访问不属于自己存储空间或只读存储空间)。

  1. SIGFPE

在发生致命的算术运算错误时发出. 不仅包括浮点运算错误, 还包括溢出及除数为0等其它所有的算术的错误。

  1. SIGKILL

用来立即结束程序的运行. 本信号不能被阻塞、处理和忽略。如果管理员发现某个进程终止不了,可尝试发送这个信号。

  1. SIGUSR1

留给用户使用

  1. SIGSEGV

试图访问未分配给自己的内存, 或试图往没有写权限的内存地址写数据.

  1. SIGUSR2

留给用户使用

  1. SIGPIPE

管道破裂。这个信号通常在进程间通信产生,比如采用FIFO(管道)通信的两个进程,读管道没打开或者意外终止就往管道写,写进程会收到SIGPIPE信号。此外用Socket通信的两个进程,写进程在写Socket的时候,读进程已经终止。

  1. SIGALRM

时钟定时信号, 计算的是实际的时间或时钟时间. alarm函数使用该信号.

  1. SIGTERM

程序结束(terminate)信号, 与SIGKILL不同的是该信号可以被阻塞和处理。通常用来要求程序自己正常退出,shell命令kill缺省产生这个信号。如果进程终止不了,我们才会尝试SIGKILL。

  1. SIGCHLD

子进程结束时, 父进程会收到这个信号。 如果父进程没有处理这个信号,也没有等待(wait)子进程,子进程虽然终止,但是还会在内核进程表中占有表项,这时的子进程称为僵尸进程。这种情 况我们应该避免(父进程或者忽略SIGCHILD信号,或者捕捉它,或者wait它派生的子进程,或者父进程先终止,这时子进程的终止自动由init进程 来接管)。

  1. SIGCONT

让一个停止(stopped)的进程继续执行. 本信号不能被阻塞. 可以用一个handler来让程序在由stopped状态变为继续执行时完成特定的工作. 例如, 重新显示提示符

  1. SIGSTOP

停止(stopped)进程的执行. 注意它和terminate以及interrupt的区别:该进程还未结束, 只是暂停执行. 本信号不能被阻塞, 处理或忽略.

  1. SIGTSTP

停止进程的运行, 但该信号可以被处理和忽略. 用户键入SUSP字符时(通常是Ctrl-Z)发出这个信号

  1. SIGTTIN

当后台作业要从用户终端读数据时, 该作业中的所有进程会收到SIGTTIN信号. 缺省时这些进程会停止执行.

  1. SIGTTOU

类似于SIGTTIN, 但在写终端(或修改终端模式)时收到.

  1. SIGURG

有”紧急”数据或out-of-band数据到达socket时产生.

  1. SIGXCPU

超过CPU时间资源限制. 这个限制可以由getrlimit/setrlimit来读取/改变。

  1. SIGXFSZ

当进程企图扩大文件以至于超过文件大小资源限制。

  1. SIGVTALRM

虚拟时钟信号. 类似于SIGALRM, 但是计算的是该进程占用的CPU时间.

  1. SIGPROF

类似于SIGALRM/SIGVTALRM, 但包括该进程用的CPU时间以及系统调用的时间.

  1. SIGWINCH

窗口大小改变时发出.

  1. SIGIO

文件描述符准备就绪, 可以开始进行输入/输出操作.

  1. SIGPWR

Power failure

  1. SIGSYS

非法的系统调用。

关键点注意

在以上列出的信号中,程序不可捕获、阻塞或忽略的信号有:SIGKILL,SIGSTOP 不能恢复至默认动作的信号有:SIGILL,SIGTRAP 默认会导致进程流产的信号有:SIGABRT,SIGBUS,SIGFPE,SIGILL,SIGIOT,SIGQUIT,SIGSEGV,SIGTRAP,SIGXCPU,SIGXFSZ 默认会导致进程退出的信号有: SIGALRM,SIGHUP,SIGINT,SIGKILL,SIGPIPE,SIGPOLL,SIGPROF,SIGSYS,SIGTERM,SIGUSR1,SIGUSR2,SIGVTALRM 默认会导致进程停止的信号有:SIGSTOP,SIGTSTP,SIGTTIN,SIGTTOU 默认进程忽略的信号有:SIGCHLD,SIGPWR,SIGURG,SIGWINCH 此外,SIGIO在SVR4是退出,在4.3BSD中是忽略;SIGCONT在进程挂起时是继续,否则是忽略,不能被阻塞。

如何捕获Crash

对于系统Crash而引起的程序异常退出,可以通过UncaughtExceptionHandler机制捕获;也就是说在程序中catch以外的内容,被系统自带的错误处理而捕获。我们要做的就是用自定义的函数替代该ExceptionHandler即可。

关于捕获Crash有个开源库值得一看

使用Objective-C的异常处理是不能得到signal的,如果要处理它,我们还要利用unix标准的signal机制,注册SIGABRT, SIGBUS, SIGSEGV等信号发生时的处理函数。该函数中我们可以输出栈信息,版本信息等其他一切我们所想要的。

1: Unix信号捕获 可以通过注册signalHandler来捕获信号:

signal(SIGSEGV,signalHandler);
复制代码

2: 对于应用级异常NSException,还需要特殊处理 某个NSException导致程序Crash的,只有拿到这个NSException,获取它的reason,name,callStackSymbols信息才能确定出问题的程序位置

@property (readonly, copy) NSExceptionName name;
@property (nullable, readonly, copy) NSString *reason;
@property (nullable, readonly, copy) NSDictionary *userInfo;

@property (readonly, copy) NSArray<NSNumber *> *callStackReturnAddresses;
@property (readonly, copy) NSArray<NSString *> *callStackSymbols;
复制代码

方法很简单,可通过注册NSUncaughtExceptionHandler捕获异常信息:

static void okUncaughtExceptionHandler (NSException *exception) {
    //这里可以取到 NSException 信息
}
NSSetUncaughtExceptionHandler(&okUncaughtExceptionHandler);
复制代码

将拿到的NSException细节写入Crash日志,精准的定位出错程序位置:

解决Crash

好了, 既然已经收集了Crash信息, 那么就撸起袖子解决吧 先别着急, 我们还得需要另外一个文件dSYM文件

首先我们得知道什么是 dSYM 文件

Xcode编译项目后,我们会看到一个同名的 dSYM 文件,dSYM 是保存 16 进制函数地址映射信息的中转文件,我们调试的 symbols 都会包含在这个文件中,并且每次编译项目的时候都会生成一个新的 dSYM 文件,位于 /Users/<用户名>/Library/Developer/Xcode/Archives 目录下,对于每一个发布版本我们都很有必要保存对应的 Archives 文件

dSYM 文件有什么作用

当我们软件 release 模式打包或上线后,不会像我们在 Xcode 中那样直观的看到用崩溃的错误,这个时候我们就需要分析 crash report 文件了,iOS 设备中会有日志文件保存我们每个应用出错的函数内存地址,通过 Xcode 的 Organizer 可以将 iOS 设备中的 DeviceLog 导出成 crash 文件,这个时候我们就可以通过出错的函数地址去查询 dSYM 文件中程序对应的函数名和文件名。大前提是我们需要有软件版本对应的 dSYM 文件,这也是为什么我们很有必要保存每个发布版本的 Archives 文件了。

拿到了Crash崩溃信息, 拿到了dSYM文件 然后就可以根据栈内存地址进行分析 然后解决之 再次给大家推荐一个好用的

你以为到这里就完了吗? 上面解决的渠道是正确的 但是修复bug之后发版时间问题, 用户和老板可不等你, 等你好不容易找到bug -> 修复bug -> 发版 -> 等待审核 -> 用户更新(还不能确保所有用户都更新), 这一趟流程下来, 估计用户早就跑到竞争对手那里了(除非你的应用有不可替代性), 老板早就扣你工资了.
不过目前来说 自从苹果禁止使用动态Framework之后就有了JSPatch或wax这样的热修复技术通过JS技术曲线救国的方式实现

说到这里大家觉得没有可以再突破的了, 赶紧去研究热修复技术吧 但是在往前想想, 就算热修复很方便, 但是用户也是继续崩着呢 能不能在抓到Crash的时候 智能解决呢, 最起码不让应用崩溃, 当然这样可能显示的数据会异常, 但是总比崩溃体验好吧. 不过bug毕竟是bug所以还是得我们通过发版或者热修复的技术来解决的

那么首先做的就是分析常见的高频Crash种类 比如 unrecognized selector, attempt to insert nil object, out of bounds 等等 如果能把这些高频出现的Crash解决掉那么崩溃率肯定会降低不少

一般Crash类型无非以下类型:

• unrecognized selector crash(找不到方法) • Container crash(数组越界,插nil等) • KVO crash • NSNotification crash • NSTimer crash • NSString crash (字符串操作的crash) • Bad Access crash (野指针) • UI not on Main Thread Crash (非主线程刷UI)

NO.1 NSInvalidArgumentException

崩溃信息

-[__NSPlaceholderDictionary initWithObjects:forKeys:count:]: attempt to insert nil object from objects[0] 
-[__NSPlaceholderArray initWithObjects:count:]: attempt to insert nil object from objects[0]
-[__NSSingleObjectArrayI objectAtIndex:]: index 3 beyond bounds [0 .. 0]
复制代码

对于NSInvalidArgumentException类型的异常出现的原因是我们在操作NSDictionary, NSArray, NSSet等集合对象时, 插入了一个nil值

解决方案: 一: 在每个赋值之前去判断是否为nil, 但是这是个体力活呀, 这样做的话对项目的改动的地方太多太多了 二: 可以用Objective-C的rumtime技术来解决此问题 部分代码如下

- (void)ok_insertObject:(id)anObject atIndex:(NSUInteger)index {
    if (anObject && index <= [self count]) {
        [self ok_insertObject:anObject atIndex:index];
    } else {
        NSLog(@"index is beyound bounds or object is nil");
    }
}

- (instancetype)ok_initWithObjects:(const id [])objects
                           forKeys:(const id<NSCopying> [])keys
                             count:(NSUInteger)cnt {
    
    id safeObjects[cnt];
    id safeKeys[cnt];
    NSUInteger j = 0;
    for (NSUInteger i = 0; i < cnt; i++) {
        id key = keys[i];
        id obj = objects[i];
        if (!key) {
            continue;
        }
        if (!obj) {
            obj = [NSNull null];
        }
        safeKeys[j] = key;
        safeObjects[j] = obj;
        j++;
    }
    return [self ok_initWithObjects:safeObjects forKeys:safeKeys count:j];
}
复制代码

NO.2 NSRangeException

崩溃信息

-[__NSSingleObjectArrayI objectAtIndex:]: index 3 beyond bounds [0 .. 0]
复制代码

对于NSRangeException异常出现的原因是我们从集合对象中取值的时候进行越界取值操作

解决方案: 同样适用runtime技术在取值的时候判断是否是越界取值 部分代码如下:

- (id)ok_objectAtIndex:(NSUInteger)index {
    if (index < [self count]) {
        return  [self ok_objectAtIndex:index];
    }
    return nil;
}
复制代码

NO.3 Unrecognized selector

崩溃信息

unrecognized selector sent to instance 0x1044a21b0
复制代码

unrecognized selector 产生的原因是一个对象调用了一个不属于它的方法 要解决这中类型的crash,我们需要先了解清楚它产生的具体原因和流程。

  1. 方法调用流程

runtime中具体的方法调用流程大致如下: 1.首先,在相应操作的对象中的缓存方法列表中找调用的方法,如果找到,转向相应实现并执行。 2.如果没找到,在相应操作的对象中的方法列表中找调用的方法,如果找到,转向相应实现执行 3.如果没找到,去父类指针所指向的对象中执行1,2. 4.以此类推,如果一直到根类还没找到,转向拦截调用,走消息转发机制。 5.如果没有重写拦截调用的方法,程序报错。

  1. 拦截调用

在方法调用中说到了,如果没有找到方法就会转向拦截调用。 那么什么是拦截调用呢 拦截调用就是,在找不到调用的方法程序崩溃之前,你有机会通过重写NSObject的四个方法来处理:

+ (BOOL)resolveClassMethod:(SEL)sel;

+ (BOOL)resolveInstanceMethod:(SEL)sel;

//后两个方法需要转发到其他的类处理

- (id)forwardingTargetForSelector:(SEL)aSelector;

- (void)forwardInvocation:(NSInvocation *)anInvocation;
复制代码

拦截调用的整个流程即Objective——C的消息转发机制。其具体流程如下图:

由上图可见,在一个函数找不到时,runtime提供了三种方式去补救: 1、调用resolveInstanceMethod给个机会让类添加这个实现这个函数 2、调用forwardingTargetForSelector让别的对象去执行这个函数 3、调用forwardInvocation(函数执行器)灵活的将目标函数以其他形式执行。 如果都不中,调用doesNotRecognizeSelector抛出异常。

unrecognized selector crash 防护方案 既然可以补救,我们完全也可以利用消息转发机制来做文章。那么问题来了,在这三个步骤里面,选择哪一步去改造比较合适呢。 这里我们选择了第二步forwardingTargetForSelector来做文章。原因如下: 1. resolveInstanceMethod 需要在类的本身上动态添加它本身不存在的方法,这些方法对于该类本身来说冗余的 2. forwardInvocation可以通过NSInvocation的形式将消息转发给多个对象,但是其开销较大,需要创建新的NSInvocation对象,并且forwardInvocation的函数经常被使用者调用,来做多层消息转发选择机制,不适合多次重写 3. forwardingTargetForSelector可以将消息转发给一个对象,开销较小,并且被重写的概率较低,适合重写 选择了forwardingTargetForSelector之后,可以将NSObject的该方法重写,做以下几步的处理: 1.动态创建一个桩类 2.动态为桩类添加对应的Selector,用一个通用的返回0的函数来实现该SEL的IMP 3.将消息直接转发到这个桩类对象上。

注意如果对象的类本事如果重写了forwardInvocation方法的话,就不应该对forwardingTargetForSelector进行重写了,否则会影响到该类型的对象原本的消息转发流程。 通过重写NSObject的forwardingTargetForSelector方法,我们就可以将无法识别的方法进行拦截并且将消息转发到安全的桩类对象中,从而可以使app继续正常运行。

NO.4 Can't add self as subview crash

造成这个崩溃的原因,一种原因是在push或pop一个视图的时候,并且设置了animated:YES,如果此时动画(animated)还没有完成,这个时候,你在去push或pop另外一个视图的时候,就会造成该异常。

UIViewController *testVC = [[UIViewController alloc] init];
[self.navigationController pushViewController:testVC animated:YES];
[self.navigationController pushViewController:testVC animated:YES];
复制代码

此类型bug是因为动画时间原因, 如果把animated设为NO. 就不会出现了 , 但是牺牲了用户体验. 因此还是得通过runtime的方式解决, 确保当有控制器正在进行入栈或出栈时,没有其他入栈或出栈操作。

NO.5 KVO类型crash防护(KVO)

KVO crash 产生原因 KVO,即:Key-Value Observing,它提供一种机制,当指定的对象的属性被修改后,则对象就会接受收到通知。简单的说就是每次指定的被观察的对象的属性被修改后,KVO就会自动通知相应的观察者了。 KVO机制在iOS的很多开发场景中都会被使用到。不过如果一不小心使用不当的话,会导致大量的crash问题 首先我们来看看通过会导致KVO Crash的两种情形:

  1. KVO的被观察者dealloc时仍然注册着KVO导致的crash,见下图

  1. 添加KVO重复添加观察者或重复移除观察者(KVO注册观察者与移除观察者不匹配)导致的crash,见下图

解决方案:

一个被观察的对象(Observed Object)上有若干个观察者(Observer),每个观察者又观察若干条KeyPath。

如果观察者和keypath的数量一多,很容易理不清楚被观察对象整个KVO关系,导致被观察者在dealloc的时候,还残存着一些关系没有被注销。 同时还会导致KVO注册观察者与移除观察者不匹配的情况发生。 笔者曾经还遇到过在多线程的情况下,导致KVO重复添加观察者或移除观察者的情况。这类问题通常多数发生的比较隐蔽,不容易从代码的层面去排查。

由上可见多数由于KVO而导致的crash原因是由于被观察对象的KVO关系图混乱导致。那么如何来管理混乱的KVO关系呢。可以让被观察对象持有一个KVO的delegate,所有和KVO相关的操作均通过delegate来进行管理,delegate通过建立一张map来维护KVO整个关系。

这样做的好处有两个: 1.如果出现KVO重复添加观察者或重复移除观察者(KVO注册观察者与移除观察者不匹配)的情况,delegate可以直接阻止这些非正常的操作。 2.被观察对象dealloc之前,可以通过delegate自动将与自己有关的KVO关系都注销掉,避免了KVO的被观察者dealloc时仍然注册着KVO导致的crash。 下面我们来看看KVO delegate的具体现实:

@interface OKKVODelegate ()

// 原始被观察对象 必须若引用
@property (nonatomic, weak) NSObject *observed;

// kvo关系列表 key为keypath
@property (nonatomic, strong) NSMutableDictionary<NSString *, NSMutableArray<OKKVOInfo *> *> *kvoInfoMap;

@end
复制代码

KVO delegate拥有两个properties: 一个是被观察的对象observed,注意这里必须弱引用。因为observed也持有delegate,否则会导致循环引用。 另一个是维护kvo关系的map:kvoInfoMap,其中key为Keypath, value是一个array,里面放HTKVOInfo的对象。 HTKVOInfo是一个专门记录KVO相关信息的类,其中包含观察着(observer),被观察着(observed),keypath,context的,注意这里也需要弱引用,原因同上。 之后 KVO delegate针对KVO相关的几个函数进行重写,并且通过method swizzling将NSObject上对应的方法交换过来,从而实现KVO通过delegate来管理的目的。 被swizzle的方法分别是:

- (void)addObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath options:(NSKeyValueObservingOptions)options context:(nullable void *)context;
- (void)observeValueForKeyPath:(nullable NSString *)keyPath ofObject:(nullable id)object change:(nullable NSDictionary<NSKeyValueChangeKey, id> *)change context:(nullable void *)context;

- (void)removeObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath context:(nullable void *)context;
- (void)removeObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath;

复制代码

关于

- (void)addObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath options:(NSKeyValueObservingOptions)options context:(nullable void *)context;
复制代码

改造流程:

将observerd对象的所有kvo相关的observer信息全部转移到KVOdelegate上,并且避免了相同kvoinfo被重复添加多次的可能性。

关于

- (void)removeObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath context:(nullable void *)context;
复制代码

改造流程:

移除一个keypath的Observer时,当delegate的kvoInfoMap中找不到key为该keypath的时候,说明此时delegate并没有持有对应keypath的observer,即说明移除了一个不匹配的观察者,此时如果再继续操作会导致app崩溃,所以应该及时中断流程,然后统计异常信息。 当keypath对应的KVOInfo列表(infoArray)为空的时候,说明此时delegate已经不再持有任何和keypath相关的observer了。这时应该调用原有removeObserver的方法将delegate对应的观察者移除。 注意到在检查遍历infoArray的时侯,除了要删除对应的info信息,还多了一步检查info.observer == nil的过程,是因为如果observer为nil,那么此时如果keypath对应的值变化的话,也会因为找不到observer而崩溃,所以需要做这一步来阻止该种情况的发生。

关于

- (void)addObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath options:(NSKeyValueObservingOptions)options context:(nullable void *)context;
复制代码

方法改造流程如下图:

delegate对于observeValueForKeyPath方法的修改最主要的地法规,在于将对应的响应方法转移给真正的KVO Observer,通过keyInfoMap找到keypath对应的KVOInfo里面预先存储好的observer,然后调用observer原本的响应方法 同时在遍历InfoArray的时候,发现info.observerw == nil的时候,需要及时将其清除掉,避免KVO的观察者observer被释放后value变化导致的crash 最后,针对 KVO的被观察者dealloc时仍然注册着KVO导致的crash 的情况 可以将NSObject的dealloc swizzle, 在object dealloc的时候自动将其对应的kvodelegate所有和kvo相关的数据清空,然后将kvodelegate也置空。避免出现KVO的被观察者dealloc时仍然注册着KVO而产生的crash

NO.6 NSNotification类型crash

NSNotification crash 产生原因 当一个对象添加了notification之后,如果dealloc的时候,仍然持有notification,就会出现NSNotification类型的crash。 NSNotification类型的crash多产生于程序员写代码时候犯疏忽,在NSNotificationCenter添加一个对象为observer之后,忘记了在对象dealloc的时候移除它。 所幸的是,苹果在iOS9之后专门针对于这种情况做了处理,所以在iOS9之后,即使开发者没有移除observer,Notification crash也不会再产生了。 不过针对于iOS9之前的用户,我们还是有必要做一下NSNotification Crash的防护的。

NSNotification crash 防护方案 NSNotification Crash的防护原理很简单, 利用method swizzling hook NSObject的dealloc函数,再对象真正dealloc之前先调用一下 [[NSNotificationCenter defaultCenter] removeObserver:self] 即可。

注意到并不是所有的对象都需要做以上的操作,如果一个对象从来没有被NSNotificationCenter 添加为observer的话,在其dealloc之前调用removeObserver完全是多此一举。 所以我们hook了NSNotificationCenter的 addObserver:(id)observer selector:(SEL)aSelector name:(NSString *)aName object:(id)anObject 函数,在其添加observer的时候,对observer动态添加标记flag。这样在observer dealloc的时候,就可以通过flag标记来判断其是否有必要调用removeObserver函数了。

未完待续

转载于:https://juejin.im/post/5aa53fb7518825558358dcda

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值