面试遇到内存管理的第一天-定时器

590 阅读9分钟

朋友问我为什么最近的文章都以面试为标题,其实也并没有在面试,其实就像新闻标题一样,用几个扎眼的关键词,博一下大家的眼球😀。最近刚好项目不忙有时间学习写点儿东西,就当是一个学习笔记。我一直认为,每次遇到问题百度或者谷歌看别人的博客,远不如看自己的博客。所以应该养成做笔记,记录开发中遇到的问题的习惯。

说到面试,虽然最近没有在面试,但是深知面试可以带来多少学习的动力,是不是很多小伙伴都是在面试前夕抱佛脚,开始复习基础知识,开始LeetCode刷题。这样可能时间上来不及,就会感觉面试压力很大,当然大佬除外🤣。所以平时开发不忙的时候,还是多学习多巩固才好。

简单聊几句下面进入正题,对于内存管理方面的知识点,无非是ARC,引用计数,以及内存泄漏这几个大方面的问题。下面先从内存泄漏开始,作为一个学习内存管理的引子。一般引起内存泄漏的原因都是因为发生了循环引用导致的对象无法正常释放。下面用定时器的使用为例,分享一下关于内存泄漏的问题。

引子 - 定时器

iOS中定时器有常见的NSTimer,还有CADisplayLink和GCD的定时器,下面逐个分析一下每一种定时器在使用中是否会产生循环引用,以及解决方案。

NSTimer

先在VC中创建一个NSTimer

self.timer = [NSTimer scheduledTimerWithTimeInterval:1.0 target:self selector:@selector(linkTest) userInfo:nil repeats:YES];

在dealloc中停止定时器

- (void)dealloc {
    [self.timer invalidate];
    NSLog(@"%s",__func__);
}

大家肯定可以猜到了,这里大概率会发生无法释放的情况,VC无法销毁,定时器也没有停止,这样使用发生了内存泄漏,是什么原因导致的呢?一起分析一下:
scheduledTimerWithTimeInterval: target:selector: userInfo: repeats:这个初始化的类方法中,传入了self作为target参数,使得timer对self产生了强引用,而self对timer也是强引用,所以发生了循环引用。

原因差不多就是这个原因,那怎么解决呢?使用weakSelf,这是首先想到的方式

__weak typeof(self) weakSelf = self;
self.timer = [NSTimer scheduledTimerWithTimeInterval:1.0 target:weakSelf selector:@selector(linkTest) userInfo:nil repeats:YES];

然后,运行之后发现,还是无法销毁,依然存在循环引用,这个方法不灵了吗?其实之前文章中分享的使用weakself解决循环引用,只适用于block产生的循环引用,而这里并没有block。

其实scheduledTimerWithTimeInterval: target:selector: userInfo: repeats:这个方法中,不管是传入self还是weakSelf,都是传入了一段内存地址,而NSTimer内部,肯定有一个强指针指向了target,所以这里是否产生强引用和传self或者weakSelf 没关系 ,主要是timer内部对aTarget是强引用。

这条路走不通只能想别的办法打破这个循环引用了。

方案一:block方式初始化

之前说的weak的方法解决循环引用是针对block,那就使用带block的类方法初始化定时器。

self.timer = [NSTimer scheduledTimerWithTimeInterval:1.0 repeats:YES block:^(NSTimer * _Nonnull timer) {
        [weakSelf linkTest];
    }];

timer 对block是强引用, 而block对self是弱引用 ,也就是self对timer是强引用 ,而timer对self是弱引用,这样就打破的循环引用,规避了内存泄漏。

方案二:中介对象

因为NSTimer对target是强引用,而且我们无法改变这个强引用的关系,所以,是否可以创建一个中介对象(或者叫中间对象),来打破循环引用呢。

先创建一个中介对象TimerProxy继承自NSObject,这个对象的主要作用就是对target产生一个弱引用:

@interface TimerProxy : NSObject
@property (nonatomic, weak) id target;
+ (instancetype)proxyWithTarget:(id)target;
@end

弱引用有了,那定时器要执行的方法还在VC中呢,虽然可以把定时器的方法也拿到TimerProxy中,但是这样做扩展性太差,不推荐使用。更好的做法是runtime相关文章中说过的消息转发,可以通过forwardingTargetForSelector:方法,将消息转发给其他对象。

@implementation TimerProxy
+ (instancetype)proxyWithTarget:(id)target {
    TimerProxy *proxy = [[TimerProxy alloc] init];
    proxy.target = target;
    return proxy;
}

//这种方法不推荐 扩展性差
//- (void)linkTest {
//
//    [self.target linkTest];
//}

//消息转发
- (id)forwardingTargetForSelector:(SEL)aSelector {
    return self.target;
}
@end

bingo,这个方案完美解决循环引用问题。

这里还想再分享一个赠送的知识点NSProxy,在分析类对象的时候,曾经提过一句,除了NSProxy之外,别的对象都继承自NSObject,那NSProxy的存在是有啥意义呢?

NSProxy

NSProxy这个类本来设计就是用来做消息转发的,一旦调用NSProxy的任意方法就会马上调用methodSignatureForSelector,下面用NSProxy实现一个中间对象,解决定时器的循环引用问题。

@implementation TimerProxy2
+ (instancetype)proxyWithTarget:(id)target {
    TimerProxy2 *proxy = [TimerProxy2 alloc];
    proxy.target = target;
    return proxy;
}

//  NSProxy没有forwardingTargetForSelector这个方法
//- (id)forwardingTargetForSelector:(SEL)aSelector {
//    return self.target;
//}

//返回target中sel的签名
- (NSMethodSignature *)methodSignatureForSelector:(SEL)sel {
    return [self.target methodSignatureForSelector:sel];
}
//使用target调用invocation
- (void)forwardInvocation:(NSInvocation *)invocation {
    [invocation invokeWithTarget:self.target];
}

注意NSProxy没有init方法,只调用alloc就可以初始化,并且NSProxy也没有forwardingTargetForSelector这个方法。

之所以分享NSProxy这个类,是因为使用NSProxy实现中介对象,效率会更高,这里要复习一下方法查找的流程,如果是继承自NSObjec,那么要先在类对象的方法列表中查找,然后再去父类的方法列表中查找,才进行消息转发。

而NSProxy呢,如果本类中没有实现这个方法,则直接进入消息转发 ,没有父类搜索的过程 ,也没有动态方法解析,少了几个查找的流程,自然效率也高了。

CADisplayLink

CADisplayLink也是一种定时器的实现方案,他不同于NSTimer,不需要设置时间间隔,他是保证调用频率和屏幕的刷帧频率一致的一种定时器,并且需要手动加入runloop中。

self.link = [CADisplayLink displayLinkWithTarget:self selector:@selector(linkTest)];
[self.link addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSDefaultRunLoopMode];

他存在着和NSTimer同样的会导致内存泄漏的问题,同样解决方案也一样,当然了,CADisplayLink没有带block的初始化方法,就只能使用中介对象来解决了。

self.link = [CADisplayLink displayLinkWithTarget:[TimerProxy proxyWithTarget:self] selector:@selector(linkTest)];
[self.link addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSDefaultRunLoopMode];

NSTimer、CADisplayLink为什么不准确

NSTimer、CADisplayLink这两种定时器,底层都是基于runloop实现的,为啥说他们都不太准确呢,还是要根据runloop的执行逻辑来分析的。

runloop是个循环,每跑一圈花费的时间是不固定的,他有一套对于时间的判断规则,比如1.0s执行一次定时器任务,比如第一圈过来是0.2s,第二圈是0.3s,3圈0.3s,如果4圈0.5s,那么1.3s才执行一次定时器任务,所以依赖runloop的定时器,是不准确的。

下面再介绍一种GCD的定时器,GCD的定时器是直接跟系统内核挂钩的,跟runloop无关,不受runloop的影响,比NSTimer、CADisplayLink这两种定时器准时很多。

dispatch_source定时器

对于GCD的定时器因为使用比较复杂,这里就主要分享一下使用和封装,就不再赘述关于内存泄漏的问题了。

//    dispatch_queue_t queue = dispatch_get_main_queue();
//    修改队列 让定时器在子线程中执行任务
    dispatch_queue_t queue = dispatch_queue_create("timer", DISPATCH_QUEUE_SERIAL);
    self.timer = dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0, queue);
    
    NSTimeInterval start = 2.0;//2s后开始执行
    NSTimeInterval inerval = 1.0;
//    NSEC_PER_SEC 纳秒
    dispatch_source_set_timer(self.timer, dispatch_time(DISPATCH_TIME_NOW, start * NSEC_PER_SEC), inerval * NSEC_PER_SEC, 0);
    
//    dispatch_source_set_event_handler(self.timer, ^{
//        NSLog(@"1");
//    });
//    设置函数
    dispatch_source_set_event_handler_f(self.timer, timerFire);
    //启动定时器
    dispatch_resume(self.timer);

GCD的定时器可以很方便的通过改变队列,让timer在子线程中执行;他有两种设置定时器执行操作的方法,可以通过block,也可以通过直接设置函数,函数可以如下这样定义

void timerFire(void *param) {
    NSLog(@"2 %@",[NSThread currentThread]);
}

在讲runloop应用的时候,说过一个定时器失效的问题,就是拖拽页面上的scrollview导致了定时器停止,停止拖动,定时器又开始继续执行。但是对于GCD的定时器,因为底层跟runloop无关,所以是不受runloop的mode改变的影响的。

封装

由于dispatch_source定时器的使用比较复杂,每次使用都要写多行代码,建议可以对他进行一些封装,让使用更加简单便捷。

封装的思路大概是,需要暴露一个执行任务的接口,可以配置基础参数,这样调用方就完全不用关心定时器的实现了,只需要设置比如是否重复执行,时间间隙以及要执行的任务就可以了。

下面贴一下核心代码:

@implementation MyTimer
static NSMutableDictionary *timers;
dispatch_semaphore_t semaphore_;

+ (void)initialize {
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        timers = [NSMutableDictionary dictionary];
    });
    
    semaphore_ = dispatch_semaphore_create(1);
}
+ (NSString *)execTask:(void(^)(void))task
           start:(NSTimeInterval)start
        interval:(NSTimeInterval)interval
         repeats:(BOOL)repeats
           async:(BOOL)async {
    if (!task || start < 0 || (interval <= 0 && repeats)) {
        return nil;
    }
 
    dispatch_queue_t queue = async ? dispatch_queue_create("timer", DISPATCH_QUEUE_SERIAL) : dispatch_get_main_queue();
    dispatch_source_t timer = dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0, queue);
    
    
    dispatch_source_set_timer(timer, dispatch_time(DISPATCH_TIME_NOW, start * NSEC_PER_SEC), interval * NSEC_PER_SEC, 0);
    
    
    //    唯一标识
    static int i = 0;
    dispatch_semaphore_wait(semaphore_, DISPATCH_TIME_FOREVER);
    NSString *name = [NSString stringWithFormat:@"%d",i++];
    timers[name] = timer;
    dispatch_semaphore_signal(semaphore_);
    
    dispatch_source_set_event_handler(timer, ^{
        task();
        if (!repeats) {
            [self cancleTask:name];
        }
        
    });
    
    //启动定时器
    dispatch_resume(timer);
    
    return name;
}

+ (NSString *)execTask:(id)target
              selector:(SEL)sel
start:(NSTimeInterval)start
              interval:(NSTimeInterval)interval
               repeats:(BOOL)repeats
                 async:(BOOL)asyn {
    if (!target || !sel) {
        return nil;
    }
    return [self execTask:^{
        if ([target respondsToSelector:sel]) {
//      强制消除警告  -Warc-performSelector-leaks警告的名称
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Warc-performSelector-leaks"
            [target performSelector:sel];
#pragma clang diagnostic pop
            
        }
    } start:start interval:interval repeats:repeats async:asyn];
}

+ (void)cancleTask:(NSString *)task {
    if (!task || task.length == 0) {
        return;
    }
    dispatch_semaphore_wait(semaphore_, DISPATCH_TIME_FOREVER);
    dispatch_source_t timer = timers[task];
    if (timer) {
        dispatch_source_cancel(timer);
        [timers removeObjectForKey:task];
    }
    dispatch_semaphore_signal(semaphore_);
    
}
@end

我们学知识呢就是为了更好的应用他,这里又复习了一下信号量的知识,因为可能是多线程访问,所以对全局静态的NSMutableDictionary读写需要做线程安全保护。

再补充一个通过#pragma强制消除警告的方法

#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Warc-performSelector-leaks"
            //需要消除警告的代码
#pragma clang diagnostic pop

这里"-Warc-performSelector-leaks"这个警告的名字,从这里找

本文就先写到这里,通过定时器导致的内存泄漏问题,引出内存管理的关键性,后面的文章继续分析内存管理的一系列知识点,如果本文对你有一点帮助,希望动动手指帮忙点赞,不胜感激。