iOS中的三种定时器


NSTimer

一、背景

定时器是iOS开发中经常使用的,但是使用不慎会造成内存泄露,因为NSTimer没有释放,控制器析构函数dealloc也没有调用,造成内存泄露。

二、使用

swift
//MARK: swift语言中是没有NSInvocation类,可以使用 OC 的方法做桥接处理
open class func scheduledTimer(timeInterval ti: TimeInterval, invocation: NSInvocation, repeats yesOrNo: Bool) -> Timer
 
//MARK: 实例方法创建的定时器需要使用 fire 来启动定时器,否则,该定时器不起作用。而且需要手动添加到runloop(RunLoop.current.add(_ timer: Timer, forMode mode: RunLoop.Mode))
@available(iOS 10.0, *)
public /*not inherited*/ init(timeInterval interval: TimeInterval, repeats: Bool, block: @escaping (Timer) -> Void)

public init(fireAt date: Date, interval ti: TimeInterval, target t: Any, selector s: Selector, userInfo ui: Any?, repeats rep: Bool)
//MARK: 类方法(静态方法)创建的定时器方法,自动开启定时器,自动加入runloop
@available(iOS 10.0, *)
open class func scheduledTimer(withTimeInterval interval: TimeInterval, repeats: Bool, block: @escaping (Timer) -> Void) -> Timer

open class func scheduledTimer(timeInterval ti: TimeInterval, target aTarget: Any, selector aSelector: Selector, userInfo: Any?, repeats yesOrNo: Bool) -> Timer

二、使用要点

1.定时器与runloop

官方文档描述:

Timers work in conjunction with run loops. Run loops maintain strong references to their timers, so you don’t have to maintain your own strong reference to a timer after you have added it to a run loop.
翻译:计时器与runlopp一起工作。Runloop维护对计时器的强引用,因此在将计时器添加到Runloop后,您不必维护自己对计时器的强引用。

-invalidate的作用
由于runloop对timer强引用,runloop如何释放timer呢?-invalidate函数就是释放timer的,来看看官方文档描述:

Stops the timer from ever firing again and requests its removal from its run loop.

据官方介绍可知,- invalidate做了两件事,首先是把本身(定时器)从NSRunLoop中移除,然后就是释放对‘target’对象的强引用。从而解决定时器带来的内存泄露问题。

内存泄露在哪?

先上一个图(为了方便讲解,途中箭头指向谁就代表强引谁)

ios 如何销毁定时器 ios定时器内存泄漏_ios 如何销毁定时器

如果创建定时器只是简单的计时,不做其他引用,那么timer对象与ViewController对象循环引用的问题就可以避免,即图中 箭头4可避免。

但是如果在定时器里做了和UIViewController相关的事情,就存在内存泄露问题,因为UIViewController引用timer,timer强引用target(就是UIViewController),同时timer直接被NSRunLoop强引用着,从而导致内存泄露。

有些人可能会说对timer对象发送一个invalidate消息,这样NSRunLoop即不会对timer进行强引,同时timer也会释放对target对象的强引,这样不就解决了吗?没错,内存泄露是解决了。

但是,这并不是我们想要的结果,在开发中我们可能会遇到某些需求,只有在UIViweController对象要被释放时才去释放timer(此处要注意释放的先后顺序及释放条件),如果提前向timer发送了invalidate消息,那么UIViweController对象可能会因为timer被提前释放而导致数据错了,就像闹钟失去了秒针一样,就无法正常工作了。所以我们要做的是在向UIViweController对象发送dealloc消息前在给timer发送invalidate消息,从而避免本末倒置的问题。这种情况就像一个死循环(因为如果不给timer发送invalidate消息,UIViweController对象根本不会被销毁,dealloc方法根本不会执行),那么该怎么做呢?

如何解决?

现在我们已经知道内存泄露在哪了,也知道原因是什么,那么如何解决,或者说怎样优雅的解决这问题呢?方式有很多.

  • NSTimer Target

将定时器中的‘target’对象替换成定时器自己,采用分类实现。

@implementation NSTimer (weakTarget)

+ (NSTimer *)weak_scheduledTimerWithTimeInterval:(NSTimeInterval)interval repeats:(BOOL)repeats block:(void (^)(NSTimer * _Nonnull))block
{
    return [self scheduledTimerWithTimeInterval:(NSTimeInterval)interval target:self selector:@selector(timerEvent:) userInfo:block repeats:repeats];
}

+ (void)timerEvent:(NSTimer *)timer
{
    void (^block)(NSTimer *timer) = timer.userInfo;
    if (block) {
        block(timer);
    }
}

@end
  • NSProxy:NSProxy

NSProxy implements the basic methods required of a root class, including those defined in the NSObjectProtocol protocol. However, as an abstract class it doesn’t provide an initialization method, and it raises an exception upon receiving any message it doesn’t respond to. A concrete subclass must therefore provide an initialization or creation method and override the forwardInvocation(_😃 and methodSignatureForSelector: methods to handle messages that it doesn’t implement itself

NSProxy 是一个抽象类,它接收到任何自己没有定义的方法他都会产生一个异常,所以一个实际的子类必须提供一个初始化方法或者创建方法,并且重载forwardInvocation:方法和methodSignatureForSelector:方法来处理自己没有实现的消息。

- (void)forwardInvocation:(NSInvocation *)invocation;
- (nullable NSMethodSignature *)methodSignatureForSelector:(SEL)sel NS_SWIFT_UNAVAILABLE("NSInvocation and related APIs not available");

从类名来看是代理类,专门负责代理对象转发消息的。相比NSObject类来说NSProxy更轻量级,通过NSProxy可以帮助Objective-C间接的实现多重继承的功能。

解决方案:利用消息转发来断开NSTimer对象与视图之间的引用关系。初始化NSTimer时把触发事件的target替换成一个单独的对象,然后这个对象中NSTimer的SEL方法触发时让这个方法在当前的视图self中实现。

#import <Foundation/Foundation.h>

@interface YLWeakselfProxy : NSProxy

@property (nonatomic, weak) id target;

+ (instancetype)proxyWithTarget:(id)target;

@end
#import "YLWeakselfProxy.h"

@implementation YLWeakselfProxy

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

- (NSMethodSignature *)methodSignatureForSelector:(SEL)sel
{
    
    if (self.target && [self.target respondsToSelector:sel]) {
        return [self.target methodSignatureForSelector:sel];
    }
    return [super methodSignatureForSelector:sel];
}

- (void)forwardInvocation:(NSInvocation *)invocation
{
    SEL sel = [invocation selector];
    
    if (self.target && [self.target respondsToSelector:sel]) {
        [invocation invokeWithTarget:self.target];
    }else{
        [super forwardInvocation:invocation];
    }
}
@end
@interface NSTimerViewController ()

@property (nonatomic, weak) NSTimer *timer;

@end

@implementation NSTimerViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    
    self.title = @"NSTimerViewController";
    
    self.view.backgroundColor = [UIColor redColor];
    
    //方法一 (中间代理对象)
    NSTimer *timer = [NSTimer scheduledTimerWithTimeInterval:1.0 target:[YLWeakselfProxy proxyWithTarget:self] selector:@selector(runTimer) userInfo:nil repeats:YES];
    [[NSRunLoop currentRunLoop] addTimer:timer forMode:NSDefaultRunLoopMode];
    self.timer = timer;  
}

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

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

思路就是使用block的形式替换掉原先的“target-selector”方式,打断_timer对于其他对象的引用,
官方已经在iOS10之后加入了新的api,从而支持了block形式创建timer:

/// Creates and returns a new NSTimer object initialized with the specified block object and schedules it on the current run loop in the default mode.
/// - parameter: ti The number of seconds between firings of the timer. If seconds is less than or equal to 0.0, this method chooses the nonnegative value of 0.1 milliseconds instead
/// - parameter: repeats If YES, the timer will repeatedly reschedule itself until invalidated. If NO, the timer will be invalidated after it fires.
/// - parameter: block The execution body of the timer; the timer itself is passed as the parameter to this block when executed to aid in avoiding cyclical references
+(NSTimer *)scheduledTimerWithTimeInterval:(NSTimeInterval)interval repeats:(BOOL)repeats block:(void (^)(NSTimer *timer))block API_AVAILABLE(macosx(10.12), ios(10.0), watchos(3.0), tvos(10.0));

根据翻译,加入block形式就是为了避免引用循环。简单来说就是使用userInfo这个参数去传递block给selector去进行执行,target是timer自己,不会造成引用循环。还有一个需要注意的地方就是规避block的引用循环,为什么之类的详细解释不在这说了。

特性

  • 精度不准确,存在延迟
  • 不管是一次性的还是周期性的timer的实际触发事件的时间,都会与所加入的RunLoop和RunLoop Mode有关,如果此RunLoop正在执行一个连续性的运算,timer就会被延时出发。重复性的timer遇到这种情况,如果延迟超过了一个周期,则会在延时结束后立刻执行,并按照之前指定的周期继续执行。
  • 必须加入Runloop

CADisplayLink

文档官方:

A timer object that allows your application to synchronize its drawing to the refresh rate of the display.

CADisplayLink其实就是一个定时器对象,是一个能让我们以和屏幕刷新率(60HZ)同步的频率将特定的内容画到屏幕上的定时器类。CADisplayLink跟CoreAnimation类都属于QunartzCore.framework中API。

CADisplayLink的使用

self.displayLink = [CADisplayLink displayLinkWithTarget:self                                             selector:@selector(timerRunEvent)];
self.displayLink.frameInterval = 60;
[self.displayLink addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSRunLoopCommonModes];

创建CADisplayLink并将其添加到Runloop中。这样timerRunEvent(@selector)就会被周期调用,其中使用frameInterval设置调用的间隔,上方代表每秒调用一次(因为屏幕的刷新频率为60HZ,即每秒60次)。

[self.displayLink invalidate];
self.displayLink = nil;

关于精度

相对于NSTimer,CADisplayLink的精度更加准确些,毕竟苹果设备的屏幕的刷新频率是固定的,都是60HZ。而CADisplayLink在每次刷新结束后都会被调用,精度会比较高。同时我们也可以根据CADisplayLink这个特性来检测屏幕是否会出现掉帧现象,如:<YYKit 中计算当前界面每秒 FPS 帧数的小组件>
就是根据此种原理。

关于使用场景

CADisplayLink的使用场景比较单一,适用于UI、动画的绘制与渲染。而比较著名的Facebook开源的第三方库POP就是基于CADisplayLink的,性能上比系统的CoreAnimation更加优秀。

而NSTimer在使用上就会更加的广泛了,基本很多场景都可使用。不管是一次性的还是连续周期性的timer事件,都会将NSTimer对象添加到Runloop中,但当Runloop正在执行另一个任务时,timer就会出现延迟。

特性

  • 屏幕刷新时调用CADisplayLink是一个能让我们以和屏幕刷新率同步的频率将特定的内容画到屏幕上的定时器类。CADisplayLink以特定模式注册到runloop后,每当屏幕显示内容刷新结束的时候,runloop就会向CADisplayLink指定的target发送一次指定的selector消息, CADisplayLink类对应的selector就会被调用一次。
  • 延迟iOS设备的屏幕刷新频率是固定的,CADisplayLink在正常情况下会在每次刷新结束都被调用,精确度相当高。但如果调用的方法比较耗时,超过了屏幕刷新周期,就会导致跳过若干次回调调用机会。如果CPU过于繁忙,无法保证屏幕60次/秒的刷新率,就会导致跳过若干次调用回调方法的机会,跳过次数取决CPU的忙碌程度。
  • tolerance属性用于设置可以容忍的触发时间的延迟范围。
  • 同样注意内存泄露问题,原理和NSTimer一样。

CGD定时器

A dispatch source that submits the event handler block based on a timer.

大概意思是分派源基于计时器提交事件处理程序块。dispatch_source_t的定时器不受RunLoop影响,而且dispatch_source_t是系统级别的源事件,精度很高,系统自动触发。

/**
 * 创建DispatchSourceTimer对象
 * flags: 一个数组,(暂时不知干吗用的)
 * queue: timer 在那个队列里面执
 */
public class func makeTimerSource(flags: DispatchSource.TimerFlags = [], queue: DispatchQueue? = nil) -> DispatchSourceTimer
/**
 * 单次执行
 * deadline: 什么时候开始
 */
public func scheduleOneshot(deadline: DispatchTime, leeway: DispatchTimeInterval = .nanoseconds(0))

/**
 * 重复执行
 * deadline: 什么时候开始
 * repeating: 调用频率,即多久调用一次
 * leeway: 误差时间(微秒)
 */
public func schedule(deadline: DispatchTime, repeating interval: Double, leeway: DispatchTimeInterval = .nanoseconds(0))

/**
 * 事件回调
 * handler: 回调事件
 */
public func setEventHandler(handler: DispatchWorkItem)
import UIKit

class YLTimerTool: NSObject {

    private var timer: DispatchSourceTimer?
    override init() {
        super.init()
    }
    
    deinit {
        timer?.cancel()
        timer = nil
    }
    
    func gcdDispatchTime(intervel: TimeInterval, eventHandleClosure:@escaping (() -> Void)){
        
        if  timer == nil {
            timer = DispatchSource.makeTimerSource(flags: [], queue: DispatchQueue.global())
            //2. 默认主线程
//            let timer = DispatchSource.makeTimerSource()
            timer?.schedule(deadline: DispatchTime.now(), repeating: intervel, leeway: .milliseconds(10))
            timer?.setEventHandler(handler: {
                DispatchQueue.main.async {
                    eventHandleClosure()
                }
            })
            self.resume()
        }else{
            timer?.setEventHandler(handler: {
                DispatchQueue.main.async {
                    eventHandleClosure()
                }
            })
        }
    }
    // 销毁
    func invalidate() {
        timer?.cancel()
        timer = nil
    }
    // 挂起()
    func stop() {
        timer?.suspend()
    }
    // 启动
    func resume() {
        timer?.resume()
    }
}

特性

  • GCD定时器实际上是使用了dispatch源(dispatch source),dispatch源监听系统内核对象并处理。dispatch类似生产者消费者模式,通过监听系统内核对象,在生产者生产数据后自动通知相应的dispatch队列执行,后者充当消费者。通过系统级调用,更加精准。
  • 可以使用子线程,解决定时间跑在主线程上卡UI问题
  • 需要将dispatch_source_t timer设置为成员变量,不然会立即释放