美文网首页iOS分享世界iOS开发iOS开发进阶
iOS开发进阶:性能优化与稳定性优化实践

iOS开发进阶:性能优化与稳定性优化实践

作者: __Null | 来源:发表于2022-01-03 22:17 被阅读0次

    优化实践主要包括UI界面的优化、稳定性的优化两部分,是在开发过程中对于相关问题的认知和解决方案,仅代表个人观点,如有疑问,欢迎一起探讨学习。

    一、UI界面优化

    在渲染流程中GPU、CPU、显示器协同工作。CPU计算好显示的内容(包括视图的创建、布局计算、图片解码、文本绘制等),再提交打给GPU进行变换、图层合成、纹理渲染,并将渲染的结果提交到帧缓冲区,等带下一次VSync信号显示到屏幕上。

    针对这个问题,可以分别对CPU、GPU做一些方面的优化:

    • 针对CPU的优化:在子线程进行对象的创建、调整、销毁;在子线程中预排版、预渲染;异步绘制等等
    • 针对GPU的优化:避免离屏渲染,减少涂层的复杂度等。
    1.界面布局优化之预排版

    UITableViewUICollectionView中单元格的现实需要提供给代理方法对应的高度),以快速决定后续单元格布局的位置,而单元格高度与实际渲染的数据相关。我们可以在heightForRow(...)cellForRow(...)方法中通过临时布局计算单元格的高度和实际数据的渲染,但是这样一来就进行了多次布局计算,如果界面非常复杂,这里势必会出现卡顿。

    • 解决方案:网络数据返回后进行布局运算,生成数据模型。比如复杂业务逻辑的判断、图像显示的frame、文本显示的frame、单元格height、富文本拼接、折叠展开数据的计算。计算完毕之后再切换到主线程刷新用户界面。1.heightForRow(...)中通过遍历找到模型,返回模型上计算好的高度。2.cellForRow(...)方法中使用计算好的模型进行用户界面的布局显示。
    • 优缺点:一次布局计算完成后后续可以直接取出来进行渲染,避免多次布局计算,体改滑动渲染的效率。如果数据量特别大,可以在预估数据能覆盖整个屏幕的情况下切换到主线程进行界面的渲染,计算完成后再刷新一下用户界面。
    2、界面渲染优化之预解码/预渲染

    图像的现实需要网络获取到图片的Data-Buffer,再解码生成Image-Buffer,而整个解码的过程是比较耗费性能的。如果有大量的网络图片需要加载,这里可能就会造成一定程度的卡顿。

    • 解决方案:网络图片在子线程中提前解码,然后将解码后的数据绑定在模型上。显示的时候直接设置模型中的数据。

    关于这一点在创建的图片加载网络框架中都是有迹可循的,比如在SDWebImage框架中SDWebImageDownloaderOperation中:

    //@property (strong, nonatomic, nonnull) NSOperationQueue *coderQueue; // the serial operation queue to do image decoding
    
    - (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task didCompleteWithError:(NSError *)error {
        
        [self.coderQueue addOperationWithBlock:^{
            // decode the image in coder queue, cancel all previous decoding process
            UIImage *image = SDImageLoaderDecodeImageData(imageData, self.request.URL, [[self class] imageOptionsFromDownloaderOptions:self.options], self.context); 
        }]; 
    }
    
    3、界面渲染优化之异步绘制

    根据CPU、GPU渲染原理,由于垂直同步的机制,如果在一个 VSync 时间内,CPU 或者 GPU 没有完成内容提交,则那一帧就会被丢弃,等待下一次机会再显示,而这时显示屏会保留之前的内容不变。这就是界面卡顿的原因。从上图中可以看到,CPU 和 GPU 不论哪个阻碍了显示流程,都会造成掉帧现象。

    整个流程可以通过如下代码进行验证:

    @interface NXAsyncableLayer : CALayer
    @end
    @implementation NXAsyncableLayer
    - (void)setNeedsDisplay {
        NSLog(@"%s", __func__);
        [super setNeedsDisplay];
    }
    
    - (void)display {
        NSLog(@"%s", __func__);
        [super display];
    }
    
    - (void)drawInContext:(CGContextRef)ctx {
        NSLog(@"%s", __func__);
        [super drawInContext:ctx];
    }
    
    - (void)renderInContext:(CGContextRef)ctx{
        NSLog(@"%s", __func__);
        [super renderInContext:ctx];
    }
    @end
    
    @interface NXAsyncableLabel : UILabel
    @end
    @implementation NXAsyncableLabel
    - (void)setNeedsDisplay {
        NSLog(@"%s", __func__);
        [super setNeedsDisplay];
    }
    
    - (void)setNeedsDisplayInRect:(CGRect)rect{
        NSLog(@"%s", __func__);
        [super setNeedsDisplayInRect:rect];
    }
    
    - (void)drawLayer:(CALayer *)layer inContext:(CGContextRef)ctx {
        NSLog(@"%s", __func__);
        [super drawLayer:layer inContext:ctx];
    }
    
    - (void)drawRect:(CGRect)rect {
        NSLog(@"%s", __func__);
        [super drawRect:rect];
    }
    
    - (void)displayLayer:(CALayer *)layer {
        NSLog(@"%s", __func__);
    
        dispatch_async(dispatch_get_global_queue(0, 0), ^{
           //1.异步绘制,切换至子线程
           UIGraphicsBeginImageContextWithOptions(size, NO, scale);
           //2.获取当前上下文
           CGContextRef context = UIGraphicsGetCurrentContext();
           //3.进行异步绘制(略)
           //4.生成位图
           UIImage *image = UIGraphicsGetImageFromCurrentImageContext();
           UIGraphicsEndImageContext();
    
           dispatch_async(dispatch_get_main_queue(), ^{
               //5.子线程完成工作,切换至主线程
               self.layer.contents = (__bridge id)image.CGImage;
            });
       });
    }
    
    + (Class)layerClass {
        return [NXAsyncableLayer class];
    }
    @end
    
    • 测试代码
    NXAsyncableLabel *label = [[NXAsyncableLabel alloc] initWithFrame:CGRectMake(0, 0, 100, 100)];
    label.text = @"NXAsyncableLabel";
    [self.view addSubview:label];
    
    • [NXAsyncableLabel displayLayer]打开,打印结果为:
    -[NXAsyncableLabel setNeedsDisplay]
    -[NXAsyncableLayer setNeedsDisplay] 
    -[NXAsyncableLayer display]
    -[NXAsyncableLabel displayLayer:]
    
    • [NXAsyncableLabel displayLayer]屏蔽掉,打印结果为:
    -[NXAsyncableLabel setNeedsDisplay]
    -[NXAsyncableLayer setNeedsDisplay]
    -[NXAsyncableLayer display]
    -[NXAsyncableLayer drawInContext:]
    -[NXAsyncableLabel drawLayer:inContext:]
    -[NXAsyncableLabel drawRect:]
    
    • [NXAsyncableLabel displayLayer]屏蔽掉并且将NXAsyncableLabel.layer.delegate设置为nil,打印结果为:
    -[NXAsyncableLabel setNeedsDisplay]
    -[NXAsyncableLayer setNeedsDisplay]
    -[NXAsyncableLayer display]
    -[NXAsyncableLayer drawInContext:]
    
    如上归纳总结如下:
    • [UIView setNeedsDisplay]会调用[CALayer setNeedsDisplay]方法,并并打上标记。在runloop将要结束的时候调用[CALayer display]方法。
    • 接下来判断是否实现了[UIView displayLayer:]
    • 如果实现了则调用[UIView displayLayer:],最终生成一张位图,赋值给layer.contents,完成自定义绘制流程。这里的绘制可以在子线程中完成,生成位图后再切换到子线程设置layer.contents。(CGBitmapContextCreate创建位图、CoreGraphic绘制、CGBitmapContextCreatImage生成CGImage图片).
    • 如果没有则调用[CALayer drawInContext:],如果CALayer.delegate不为空继续调用[UIView drawLayer:inContext:][UIView drawRect:]完成系统默认的同步绘制流程。
    绘制流程

    假如视图非常复杂(子视图较多、布局相互依赖、有大量图片需要解码),那么这个CPU+GPU的工作就可能超过1帧的时间,这样在快速滑动的过程中就会造成卡顿,接口给我们提供了优化的空间,也就是在[UIView displayLayer:]中,自己进行计算布局和绘制,整个过程中我们可以放在子线程中进行,不影响主线程处理滑动等其他的UI事务,这样就不会卡顿。需要补充一点,如果有大量的计算在整个滑动的过程中有时候会出现局部的空白,这是正常的,毕竟计算布局是在子线程中异步操作的,如果没有计算完毕则渲染出来的就会没有内容。

    详细的绘制流程和原理可以参考YYKit开源框架YYAsyncLayerYYLabel的实现流程。

    4、界面渲染优化之离屏渲染

    离屏渲染的检测方式:选中模拟器 ->Debug -> Off Off-screen Rendered,如果使用离屏渲染会有黄色的背景,比如系统的电池。

    GPU的渲染分为当前屏幕渲染(On-Screen Rendering)和离屏渲染(Off-Screen Rendering)。当前屏幕渲染的原理上面已经介绍了,在一次Vsync信号周期内CPU计算好布局等,然后将计好的内容交给GPU渲染。GPU渲染好之后就会放入帧缓冲区。所谓离屏渲染就是指GPU在当前屏幕的帧缓冲区意外开辟一个新的缓冲区进行渲染操作。

    那为什么说离屏渲染耗费性能的呢?

    • 创建新的缓冲区:要想进行离屏渲染,首先需要创建一个新的缓冲区。
    • 上下文切换:离屏渲染的整个过程,需要多次切换上下文环境。先是从当前屏幕切换到离屏缓冲区,渲染结束后将离屏缓冲区的数据渲染到屏幕上有需要从离屏缓冲区切换到当前屏幕。

    造成离屏渲染的原因有很多,比如shouldRasterize(光栅化)、mask(遮罩层)、shadows(阴影)、EdgeAnntialiasing(抗锯齿)、cornerRadius(圆角)等等

    • 为图层设置遮罩layer.mask
    • 将图层的layer.masksToBounds / view.clipsToBounds属性设置为true
    • 将图层layer.allowsGroupOpacity属性设置为YES和layer.opacity小于1.0
    • 为图层设置阴影layer.shadow *
    • 为图层设置layer.shouldRasterize = true
    • 具有layer.cornerRadiuslayer.edgeAntialiasingMasklayer.allowsEdgeAntialiasing的图层
    • 文本(任何种类,包括UILabelCATextLayerCore Text等)
    • 使用CGContextdrawRect :方法中绘制大部分情况下会导致离屏渲染,甚至仅仅是一个空的实现
    • iOS 9.0之前UIimageViewUIButton设置圆角都会触发离屏渲染。
    • iOS 9.0之后UIButton设置圆角会触发离屏渲染,而UIImageViewpng图片设置圆角不会触发离屏渲染了,如果设置其他阴影效果之类的还是会触发离屏渲染的。

    针对以上问题我们针对性的做出优化方案:

    • 关于圆角的处理方案,使用CAShapeLayer+UIBezierPath方案来盖住圆角部分。CAShapeLayer使用GPU渲染,专业的人做专业的事情,效率更高。
    UIImageView *imageView = [[UIImageView alloc] initWithFrame:CGRectMake(100, 100, 100, 100)];
    imageView.image = [UIImage imageNamed:@"test.png"];
    
    UIBezierPath *maskPath = [UIBezierPath bezierPathWithRoundedRect:imageView.bounds byRoundingCorners:UIRectCornerAllCorners cornerRadii:imageView.bounds.size];
        
    CAShapeLayer *maskLayer = [[CAShapeLayer alloc] init];
    maskLayer.frame = imageView.bounds;
    maskLayer.path = maskPath.CGPath;
    imageView.layer.mask = maskLayer;
    [self.view addSubview:imageView];
    

    或者采用YYImage中对图片圆角边框的处理方式,内部处理了圆角和边框(边框宽度、颜色)等多种需求,内部使用CoreGraphics+UIBezierPath的方案,绘制圆角、绘制边框,生成新的图片。

    - (UIImage *)imageByRoundCornerRadius:(CGFloat)radius
                                  corners:(UIRectCorner)corners
                              borderWidth:(CGFloat)borderWidth
                              borderColor:(UIColor *)borderColor
                           borderLineJoin:(CGLineJoin)borderLineJoin{}
    

    在实际开发中,需要注意:

    • 少量的离屏渲染不会带来性能上的影响,不用为了优化容不下一点离屏渲染。
    • 重要需要优化的应当放在UITableViewUICollectionView这种长列表的中。
    • 如果有大量的网络图片需要加载,这个时候添加圆角使用第一种方式更为便捷。

    二、稳定性优化

    App Crash的常见类型主要包括以下几种:

    • unrecognized selector crash(没找到方法的实现)
    • KVO crash
    • NSTimer crash
    • Container crash/NSString(数组越界,插nil等)
    • Bad Access crash (野指针)
    • NSNotification crash
    1.unrecognized selector crash没有找到方法的实现

    在解决这个问题的,我们先了解一下方法调用的流程:

    • 1.快速查找流程:OC方法的调用底层通过objc_msgSend(obj, sel)实现的(源码汇编编写),从实例的class->cache->_bucketsAndMaybeMask中查找方法的实现。如果没有找到,则进入2.
    • 2.慢速查找流程:从当前类到父类的方法列表中一次查找调用的方法。如果没有找到,则进入3.
    • 3.消息转发流程:
    • 3.1.动态决议:+ (BOOL)resolveInstanceMethod:(SEL)sel/+(BOOL)resolveClassMethod:(SEL)sel,可以动态向类添加他自身不存在的方法实现。
    • 3.2.快速转发:- (id)forwardingTargetForSelector:(SEL)aSelector, 动态指定一个可以执行该方法的实例。
    • 3.3.慢速转发:- (void)forwardInvocation:(NSInvocation *)anInvocation,可以实现一个空函数。也可以重新指定消息的targetselector触发消息。
    • 4.报错:doesNotRecognizeSelector
      从上面的流程中,我们可以看到在消息转发流程中,我们可以有三次机会去补救。但是每个方法各有侧重,第一个方法会向类添加一些冗余的方法;第三个需要创建方法的签名和NSInvocation有一定的开销,最合适的是第二个方法。

    推荐一种较为优雅的做法:我们可以创建一个"傀儡"类,动态为该类添加无法执行的Selector方法,然后用一个通用的方法作为该Selector的实现,将消息转发到该傀儡类的实例上。

    2.KVO crash

    KVO导致的Crash主要原因有两方面:

    • KVO的被观察者dealloc时仍然注册着KVO导致Crash。
    • 重复添加KVO观察者或者重复移除观察者。

    那么解决这个问题,就是保证KVO的观察者dealloc的时候,移除观察者,并且保证不重复添加移除观察者。所以内部维护一个观察者的关系映射是十分有必要的。
    这里可以参考我的开源框架NXKitNXKVOObserver类,它的原理很简单:

    open class NXKVOObserver : NSObject {
        //弱引用观察者
        public fileprivate(set) weak var observer : NSObject? = nil
        //内部维护一个被观察信息的列表[NXKVOObserver.Observation]
        public fileprivate(set) var observations = [NXKVOObserver.Observation]()
        
        //被观察者的信息实体
        open class Observation : NSObject {
            //弱引用被观察者
            weak open var object : NSObject? = nil
            open var key = ""
            open var options: NSKeyValueObservingOptions = []
            open var context: UnsafeMutableRawPointer? = nil
            open var completion : NX.Completion<String, [NSKeyValueChangeKey : Any]?>? = nil
            
            public init(object:NSObject, key:String, options:NSKeyValueObservingOptions, context:UnsafeMutableRawPointer?, completion:NX.Completion<String, [NSKeyValueChangeKey : Any]?>?) {
                self.object = object
                self.key = key
                self.options = options
                self.context = context
                self.completion = completion
            }
        }
        
        //初始化
        public init(observer:NSObject) {
            self.observer = observer
        }
        
        //添加观察者和观察者的属性:判断重复?!只有不重复的才会真正添加
        open func add(object: NSObject, key: String, options: NSKeyValueObservingOptions = [], context: UnsafeMutableRawPointer? = nil, completion:NX.Completion<String, [NSKeyValueChangeKey : Any]?>? = nil){
            if self.observations.contains(where: { kvo in return kvo.object == object && kvo.key == key}) {
                
            }
            else {
                let observation = NXKVOObserver.Observation(object: object, key: key, options:options, context: context, completion: completion)
                self.observations.append(observation)
                object.addObserver(self, forKeyPath: key, options: options, context: context)
            }
        }
        
        //移除观察者
        open func remove(object: NSObject, key: String) {
            if let index = self.observations.firstIndex(where: { kvo in return kvo.object == object && kvo.key == key}){
                self.observations.remove(at: index)
                object.removeObserver(self, forKeyPath: key)
            }
        }
        
        //移除所有观察者
        open func removeAll() {
            for observation in self.observations {
                observation.object?.removeObserver(self, forKeyPath: observation.key)
            }
            self.observations.removeAll()
        }
        
        //拦截回调:可以通过闭包回调或者通过observeValue(...)方法回调
        open override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
            if let __object = object as? NSObject, let observation = self.observations.first(where: { kvo in return kvo.object == __object && kvo.key == keyPath}){
                if observation.completion != nil {
                    observation.completion?(observation.key, change)
                }
                else if let __observer = self.observer,__observer.responds(to: #selector(NSObject.observeValue(forKeyPath:of:change:context:))) == true {
                    __observer.observeValue(forKeyPath: observation.key, of: observation.object, change: change, context: context)
                }
            }
        }
        
        deinit {
            NX.print(NSStringFromClass(self.classForCoder))
        }
    }
    

    如何使用?以WebViewController观察WebView为例:

    open class NXWebViewController: NXViewController {
        ...
        open var webView = NXWebView(frame: CGRect.zero)
        //初始化
        lazy var observer : NXKVOObserver = {
            return NXKVOObserver(observer: self)
        }()
    
        override open func viewDidLoad() {
           super.viewDidLoad()
           ....
           //添加被观察者和属性
           self.observer.add(object:self.webView, key: "title", options: [.new, .old], context: nil, completion: nil)
           self.observer.add(object:self.webView, key: "estimatedProgress", options: [.new, .old], context: nil, completion: nil)
        }
    
        override open func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
            
        }
        
        deinit {
            self.webView.stopLoading()
            //移除全部的被观察者
            self.observer.removeAll()
        }
    }
    

    整个结构非常轻巧,特别注意这里边的观察者observer和被观察者object都采用了弱引用,不会有循环引用的问题,那么在观察者dealloc中调用一下removeAll()即可;并且内部维护了观察者信息的列表,所有的添加、移除、回调都会查找这个列表的数据,所以不存在重复添加移除的问题了。

    如果觉得在多线程中操作不安全,可以在add(...)remove(...)removeAll(...)observeValue(...)位置添加一把锁。

    3.NSTimer crash

    我们一般使用[NSTimer scheduledTimerWithTimeInterval:target:selector:userInfo:repeats: ]做重复性的定时任务,但是这个API会强引用target实例,默认形成循环引用。为此我们需要在合适的时机invalidate定时器,断开引用环,否则就会因为循环引用双发都无法释放,导致内存泄露,甚至无限重复调用会导致资源的浪费。
    解决这个问题的关键就是在合适的时机断开引用环,这里推荐如下方案:

    • 推荐方案-YYKit开源框架中的NSTimer (YYAdd)类提供的方案:它的本质是将强引用的对应设置为自己,不与NSTimer的持有者构成循环引用,从而断开循环引用。
    • 推荐方案-YYKit开源框架中的YYTimer类提供的方案:如果对定时器的精度要求很高,比如不受手指滑动屏幕的影响等建议采用,内部使用dispatch_source_timer实现,并且将target设置为弱引用。
    • 可以根据实际的需要在viewWillDisappear(:)/viewDidDisappear(:)设置定时器失效-打开新的页面或者页面返回的时候都会调用。或在didMoveToParentViewController(:)设置定时器的实效-该方法会调用2次,页面载入完成(viewDidAppear)之后会调用一次parent不为空,页面返回(viewDidDisappear)之后会调用一次parent为空,打开新的页面(viewDidDisappear)之后不会调用。
    4.Container/NSString crash(数组越界,插nil等)

    Container Crash是指NSArray/NSMutableArray/NSDictionary/NSMutableDictionary/NSCache等类的越界访问或者插入nil等错误操作造成的。
    解决方案:可以swizzle对应的插值和访问方法,在swizzle的方法中做好空值和下标越界的判断即可,也可以自己定义一套C API可以更简洁的插值和读取,同时内部做好空值和越界的判断。两种方式各有利弊,前者有一定的侵入性,后者拦截不是特别彻底。
    NSString和NSMutableString的崩溃闪退问题,通常是越界操作引起的,处理方式同上。

    5.Bad Access crash (野指针))

    野指针造成的crash是我们开发中占比较高的一个问题,Xcode提供了检测僵尸对象Zombie的机制,能够在发生野指针的时候提示出现野指针的类,从而解决开发阶段出现的野指针问题,对于线上发生的野指针问题依旧不好排查。

    6.NSNotification crash

    这个问题主要是由于在NSNotificationCenter添加一个对象为observer之后,如果在observer dealloc的时候,没有调用[[NSNotificationCenter defaultCenter] removeObserver:self]会导致崩溃。这个问题出现在iOS 9.0之前,高版本苹果对此做了优化,不会再有这个问题了。这个推荐在控制器等基类的dealloc方法中添加[[NSNotificationCenter defaultCenter] removeObserver:self]的调用即可。网上也有一些说法说是hook add方法,hook dealloc方法,这些都是方法,个人感觉太重了~~。

    三、异常的收集

    在应用启动之后会对objc运行时异常回调进行初始化,异常回调用到_objc_terminate函数:

    static void _objc_terminate(void){
        if (! __cxa_current_exception_type()) {
            // No current exception.
            (*old_terminate)();
        }
        else {
            // There is a current exception. Check if it's an objc exception.
            @try {
                __cxa_rethrow();
            } @catch (id e) {
                // It's an objc object. Call Foundation's handler, if any.
                (*uncaught_handler)((id)e);
                (*old_terminate)();
            } @catch (...) {
                // It's not an objc object. Continue to C++ terminate.
                (*old_terminate)();
            }
        }
    }
    

    如果捕获到objc异常,回调用uncaught_handler(e),并将异常信息传递回去。uncaught_handler有个默认值是_objc_default_uncaught_exception_handler,该函数是空实现。在该文件中可以找到另外一个地方给uncaught_handler赋值:

    /***********************************************************************
    * objc_setUncaughtExceptionHandler
    * Set a handler for uncaught Objective-C exceptions. 
    * Returns the previous handler. 
    **********************************************************************/
    objc_uncaught_exception_handler objc_setUncaughtExceptionHandler(objc_uncaught_exception_handler fn){
        objc_uncaught_exception_handler result = uncaught_handler;
        uncaught_handler = fn;
        return result;
    }
    

    在Foundation层有一个

    typedef void NSUncaughtExceptionHandler(NSException *exception);
    FOUNDATION_EXPORT void NSSetUncaughtExceptionHandler(NSUncaughtExceptionHandler * _Nullable);
    

    我们可以在应用启动完成后调用该函数,然后捕获异常信息,并将该信息先保存到本地,等下一次应用启动的时候再将该信息通过接口提交给服务器。

    @implementation EXExceptionHandler
    + (instancetype)center {
        static dispatch_once_t t;
        static EXExceptionHandler *center = nil;
        dispatch_once(&t, ^{
            center = [[self alloc] init];
        });
        return center;
    }
    
    - (void)start{
        NSSetUncaughtExceptionHandler(&ExceptionHandler);
    }
    
    void ExceptionHandler(NSException *exception) {    
        NSMutableDictionary *userInfo = [NSMutableDictionary dictionaryWithDictionary:exception.userInfo];
        [userInfo setObject:exception.name forKey:EXExceptionHandlerExceptionName];
        [userInfo setObject:exception.reason forKey:EXExceptionHandlerExceptionReason];
        [userInfo setObject:exception.callStackSymbols forKey:EXExceptionHandlerExceptionCallStackSymbols];
        [userInfo setObject:@"EXException" forKey:EXExceptionHandlerExceptionFileKey];
        NSException *e = [[NSException alloc] initWithName:exception.name reason:exception.reason userInfo:userInfo];
        [EXExceptionHandler.center handleException:e];
    }
    
    - (void)handleException:(NSException *)exception{
        NSLog(@"将异常信息/设备信息/时间信息保存到本地;合适时提交到服务器:%@", exception.userInfo);
    }
    @end
    

    相关文章

      网友评论

        本文标题:iOS开发进阶:性能优化与稳定性优化实践

        本文链接:https://www.haomeiwen.com/subject/qvyfcrtx.html