内存管理-定时器

275 阅读5分钟

一、概述

在iOS开发中,我们会经常使用定时器,使用定时器可以完成每间隔一段时间,去执行我们所需要的任务。常见的定时器技术有下面三种,接下来我们依次介绍一下他们如何使用。

  • NSTimer
  • CADisplayLink
  • GCD

二、NSTimer

2.1 假如有这么一个需求:

  • 需求:应用启动后创建一个定时器,每隔1秒执行一次任务,点击控制器的View,停止定时器
  • 我们这么来设计,Window的根控制器是UINavigationController,UINavigationController的根控制器放置一个UIViewController,UIViewController上放置一个UIButton,点击按钮跳转到一个我们执行任务的定时器,来开启定时器执行任务。

@interface ViewController ()
@property (nonatomic, strong) NSTimer *timer;
@end

@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    // 创建NSTimer对象
    self.timer = [NSTimer timerWithTimeInterval:1.0 target:self selector:@selector(execute) userInfo:nil repeats:YES];
  	// 将timer添加到RunLoop启动定时器
    [[NSRunLoop mainRunLoop] addTimer:self.timer forMode:NSDefaultRunLoopMode];
}

// 点击控制器的View停止定时器
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    [self.timer invalidate];
    self.timer = nil;
}

// 执行的任务
- (void)execute {
    NSLog(@"%s", __func__);
}
// 销毁时,清除timer
- (void)dealloc {
    [self.timer invalidate];
    self.timer = nil;
    NSLog(@"%s", __func__);
}

@end
  • 上面的代码中的逻辑,进入控制器开启定时器,并执行任务,当点击控制器的view的时候,停止定时器,timer指向nil,点击返回按钮,控制器销毁,这样的逻辑没有任何问题,定时器可以停止,控制器也可以正常销毁。
  • 但是!,如果作为用户,我没有点击控制器的view,而是直接点击返回按钮,你觉得定时器会停止吗?你可能会认为dealloc方法中不是写了,清除timer了吗?**但是!**你会发现,点击Back按钮,根本就没有走dealloc方法,所以可以断定控制器根本就没有销毁。timer还在快乐的运行着,而且控制器也没有销毁,产生了内存泄漏。
  • 为什么呢?因为timer中的target对控制器产生了强引用,而控制器对timer是强引用,timer对target是强引用。

2.2 解决方案

解决方案有2种:

  • 方式一:使用block方式创建NSTimer
- (void)viewDidLoad {
    [super viewDidLoad];
    
    __weak typeof(self)weakSelf = self;
    self.timer = [NSTimer timerWithTimeInterval:1.0 repeats:YES block:^(NSTimer * _Nonnull timer) {
        [weakSelf execute];
    }];

    [[NSRunLoop mainRunLoop] addTimer:self.timer forMode:NSDefaultRunLoopMode];
}
  • 方式二:创建一个代理类LLProxy,继承自NSObject,创建一个弱引用的target,处理执行的任务
// LLProxy.h
@interface LLProxy : NSObject
+ (instancetype)proxyWithTarget:(id)target;
@end
  
// LLProxy.m
@interface LLProxy ()
@property (nonatomic, weak) id target;
@end

@implementation LLProxy

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

// ViewController.m
- (void)viewDidLoad {
    [super viewDidLoad];
    
    self.timer = [NSTimer timerWithTimeInterval:1.0 target:[LLProxy proxyWithTarget:self] selector:@selector(execute) userInfo:nil repeats:YES];
    [[NSRunLoop mainRunLoop] addTimer:self.timer forMode:NSDefaultRunLoopMode];
    
}
  • 代码写到这,直接运行,会报一个非常经典的错误**-[LLProxy execute]: unrecognized selector sent to instance 0x600000474a60'**

  • LLProxy类中找不到execute方法,我们可以把execute方法写到LLProxy类中,但是这不是我们想要的,我们还是希望execute方法在控制器中处理。这时候,我们就可以利用runtime的消息转发

// LLProxy.m
- (id)forwardingTargetForSelector:(SEL)aSelector {
    return self.target;
}

三、CADisplayLink

  • CADisplayLink和NSTimer类似,但是它是和屏幕的刷新率相关的。手机屏幕刷新,就会调用这个定时器。
  • 下面的代码产生的问题和NSTimer是一样的,参考NSTimer即可
@interface ViewController ()
@property (nonatomic, strong) CADisplayLink *timer;
@end

@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];

    self.timer = [CADisplayLink displayLinkWithTarget:self selector:@selector(execute)];
    [self.timer addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSDefaultRunLoopMode];
}

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    [self.timer invalidate];
}

- (void)execute {
    NSLog(@"%s", __func__);
}

- (void)dealloc {
    NSLog(@"%s", __func__);
}
  • 因为CADisplayLink是block方式创建的,所以想要解决上面的问题,只能使用代理类。参考NSTimer即可。

四、NSProxy

前面介绍了2种定时器可能遇到的问题和相应的解决方案。其实,苹果官方给我们提供一个类,专门来做代理的这么一个类NSProxy

  • 创建LLProxy类,继承NSProxy类
// LLProxy.h
@interface LLProxy : NSProxy

+ (instancetype)proxyWithTarget:(id)target;

@end

  • 这里注意创建LLProxy对象的方式,LLProxy *proxy = [LLProxy alloc];
@interface LLProxy ()

@property (nonatomic, weak) id target;

@end

@implementation LLProxy

+ (instancetype)proxyWithTarget:(id)target {
    
    LLProxy *proxy = [LLProxy alloc];
    proxy.target = target;
    return proxy;
}
@interface ViewController ()
@property (nonatomic, strong) NSTimer *timer;
@end

@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    
    self.timer = [NSTimer timerWithTimeInterval:1.0 target:[LLProxy proxyWithTarget:self] selector:@selector(execute) userInfo:nil repeats:YES];
    [[NSRunLoop mainRunLoop] addTimer:self.timer forMode:NSDefaultRunLoopMode];
}

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    [self.timer invalidate];
}

- (void)execute {
    NSLog(@"%s", __func__);
}

- (void)dealloc {
   [self.timer invalidate];
    NSLog(@"%s", __func__);
}
  • 上面的代码调用会直接崩溃,崩溃信息如下:意味着直接来到消息转发,省略了executef方法的查找过程,这样的话可以提高效率。
-[NSProxy methodSignatureForSelector:] called!'
- (NSMethodSignature *)methodSignatureForSelector:(SEL)sel {
    return [self.target methodSignatureForSelector:sel];
}

- (void)forwardInvocation:(NSInvocation *)invocation {
    [invocation invokeWithTarget:self.target];
}

NSProxy是专门用来做代理的,点进去看.h文件,你会发现他也是一个基类。

五、GCD创建定时器

无论使用NSTimer,还是使用CADisplayLink,本质都是添加到Runloop去处理定时器,这样就可能会产生一个问题,那就是Runloop很忙,所以没有及时处理定时器,就会导致,定时器时间不准确的问题,此时就可以使用GCD创建一个定时器,因为GCD是和内核相关的,无论从处理效率还是准确性都是比前2种好的。

- (void)viewDidLoad {
    [super viewDidLoad];

    NSLog(@"begin ----- ");
    // timer 必须是强引用才能开启定时器
    self.timer = dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0, dispatch_get_main_queue());
    // 延迟多久才开始执行
    dispatch_time_t start = dispatch_time(DISPATCH_TIME_NOW, 0.0 * NSEC_PER_SEC);
    dispatch_source_set_timer(self.timer, start, 2.0 * NSEC_PER_SEC, 0);
    
    // 这个位置必须使用弱引用,否则会产生循环引用
    // block -> self -> timer -> block
    __weak typeof(self) weakSelf = self;
    dispatch_source_set_event_handler(self.timer, ^{
        // 执行任务
        [weakSelf execute];
    });
    // 开启定时器
    dispatch_resume(self.timer);
  
}
  1. timer必须是强引用定时器才会生效
  2. dispatch_source_set_event_handler 方法中会产生循环引用,这里需要处理一下

实际开发中,建议使用GCD的 方式,来创建和处理定时器,符合奥运精神更高、更快、更强!GCD的方式,调用比较复杂不够面向对象,其实可以根据自己的方式封装一下了。我做了简单的封装,只提供.h文件,仅供参考,每个人的需求不太一样。

+ (void)timerWithTimeInterval:(NSTimeInterval)interval afterDelay:(NSTimeInterval)afterDelay repeate:(BOOL)repeat name:(NSString *)name queue:(dispatch_queue_t)queue block:(void(^)(void))block;

+ (void)cancelTimerWithName:(NSString *)name;