iOS-內存管理1-定時器、NSProxy

一. CADisplayLink、NSTimer

代碼如下:

#import "ViewController.h"
#import "MJProxy.h"

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

@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    
    self.link = [CADisplayLink displayLinkWithTarget:self selector:@selector(linkTest)];
    [self.link addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSDefaultRunLoopMode];
    
    self.timer = [NSTimer scheduledTimerWithTimeInterval:1.0 target:self selector:@selector(timerTest) userInfo:nil repeats:YES];
}

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

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

- (void)dealloc
{
    NSLog(@"%s", __func__);
    [self.link invalidate]; //讓定時器停止工作
    [self.timer invalidate]; //讓定時器停止工作
}
@end

關于上面兩個定時器:

  1. CADisplayLink這個定時器不能設置時間,保證調用頻率和屏幕刷幀頻率一致。屏幕刷幀頻率大概是60FPS,所以這個定時器一般一秒鐘調用60次。
  2. 創建NSTimer,如果是通過scheduledTimer創建,就是定制好的timer,定時器已經添加到RunLoop里面了。如果是timerWithTimeInterval創建的,就需要自己手動添加定時器到RunLoop里面。
  3. CADisplayLink、NSTimer會對target產生強引用,如果target又對它們產生強引用,那么就會引發循環引用。

顯而易見,上面兩個定時器都有循環引用的問題。
運行上面代碼,從當前VC返回,但是兩個定時器還是一直在打印,說明上面代碼的確有循環引用問題。

嘗試:

如何解決?
可能你會想,使用weakSelf啊,我們試試:

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

運行代碼,從當前VC返回,timer定時器還是一直在打印,說明上面方式無效。

為什么不能解決循環引用,以前我們不就是這么解決的嗎?
注意了,以前那是block,在block章節我們說過,如果外面是個強指針,blcok引用的時候內部就用強指針保存,如果外面是個弱指針,block引用的時候內部就用弱指針保存,所以對于block我們使用weakSelf有用。但是對于CADisplayLink、NSTimer,無論外面你傳弱指針還是強指針,都是傳入一個內存地址,定時器內部都是對這個內存地址產生強引用,所以傳弱指針沒用的。

注意:

就算不使用@property (nonatomic, strong) NSTimer *timer,使用@property (nonatomic, weak) NSTimer *timer,或者使用NSTimer *timer = [NSTimer scheduledTimerWithTimeInterval:1.0 target:self selector:@selector(timerTest) userInfo:nil repeats:YES];也會產生循環引用,因為就算VC沒有強引用timer,runLoop也會強引用timer,官方文檔解釋如下:

IMG_0672.JPG

或者我們從如下的結構圖中也可以看出timer在RunLoop的哪個地方。

結構.png

MBProgressHUD里面關于定時器的使用:

NSTimer *timer = [NSTimer timerWithTimeInterval:(self.minShowTime - interv) target:self selector:@selector(handleMinShowTimer:) userInfo:nil repeats:NO];
[[NSRunLoop currentRunLoop] addTimer:timer forMode:NSRunLoopCommonModes];
self.minShowTimer = timer;

MBProgressHUD里面這樣使用定時器為什么沒循環引用呢?因為設置了repeats:NO(就是不重復),設置不重復不會產生循環引用。
如果我們只想使用一次定時器,并且不想產生循環引用,也可以仿照MBProgressHUD一樣設置repeats:NO。

解決方案①

那么如何解決?
使用block試試,將NSTimer改成block形式的,如下:

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

這時候是,self對定時器強引用,定時器對block強引用,block對self弱引用,不產生循環引用。運行代碼,從當前VC返回,timer定時器不打印了,說明上面代碼有效。

這時候timer保存下來其實也沒啥用,我們可以寫成如下,這樣寫定時器也會正常工作的。

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

解決方案②

換成block可以解決,我們也可以用中間對象解決。

在沒使用中間對象之前,引用關系是,self里面的timer強引用著定時器,定時器里面的target強引用著self,產生循環引用。

添加中間對象之后,如下圖:

中間對象.png

控制器中的timer強引用著定時器,定時器中的target強引用著中間對象,中間對象的target弱引用著控制器,這樣就不會產生循環引用了。

我們需要做的就是當定時器找到中間對象,想要調用中間對象的timerTest方法時,我們讓中間對象調用控制器的timerTest方法。

實現代碼也很簡單,如下:

中間對象,MJProxy.h

#import <Foundation/Foundation.h>

@interface MJProxy : NSObject

+ (instancetype)proxyWithTarget:(id)target;
@property (weak, nonatomic) id target; //用弱引用

@end

中間對象,MJProxy.m

#import "MJProxy.h"

@implementation MJProxy

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

//中間對象找不到timerTest方法,就通過消息轉發,轉發給控制器
- (id)forwardingTargetForSelector:(SEL)aSelector
{
    return self.target;
}
@end

ViewController.m

#import "ViewController.h"
#import "MJProxy.h"

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

@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    
    self.link = [CADisplayLink displayLinkWithTarget:[MJProxy proxyWithTarget:self] selector:@selector(linkTest)];
    [self.link addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSDefaultRunLoopMode];

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

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

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

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

上面代碼,中間對象弱引用著控制器。當定時器啟動后,會從中間對象中尋找timerTest方法,中間對象中找不到timerTest方法,就通過消息轉發,轉發給控制器,最后調用控制器的timerTest方法。

運行代碼,從當前VC返回,兩個定時器都不打印了,說明使用中間對象有效。

對于NSTimer,無論用block解決還是用中間對象解決都可以,但是對于CADisplayLink,因為它沒有block的創建方式,所以只能使用中間對象。

二. NSProxy

以前我們說過,iOS中所有的類都繼承于NSObject,但是有一個特殊的類:NSProxy(n. 代理人;委托書;代用品)

進入NSProxy的定義:

@interface NSProxy <NSObject> {
    Class   isa;
}

再看看NSObject的定義:

@interface NSObject <NSObject> {
    Class isa ;
}

可以發現,NSProxy和NSObject是同一級別的,都遵守NSObject協議。

① NSProxy的作用

那么NSProxy有什么用呢?
其實,NSProxy就是專門做消息轉發的。

那么NSProxy比上面繼承于NSObject的中間對象好在哪里呢?
如果調用的是繼承于NSObject某個類的方法,那么它的方法尋找流程就是先查緩存,再走消息發送、動態方法解析、消息轉發,效率低。
如果調用的是繼承于NSProxy某個類的方法,那么它的方法尋找流程是,先看自己有沒有這個方法,如果沒有,就直接一步到位,來到methodSignatureForSelector方法,效率高。

② NSProxy的使用

自定義MJProxy繼承于NSProxy,使用如下:
MJProxy.h

#import <Foundation/Foundation.h>

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

MJProxy.m

#import "MJProxy.h"

@implementation MJProxy

+ (instancetype)proxyWithTarget:(id)target
{
    // NSProxy對象不需要調用init,因為它本來就沒有init方法
    MJProxy *proxy = [MJProxy alloc];
    proxy.target = target;
    return proxy;
}

//返回方法簽名
- (NSMethodSignature *)methodSignatureForSelector:(SEL)sel
{
    return [self.target methodSignatureForSelector:sel];
}

//NSInvocation封裝了一個方法調用,包括:方法調用者、方法名、方法參數
- (void)forwardInvocation:(NSInvocation *)invocation
{
    [invocation invokeWithTarget:self.target];
}
@end

當定時器啟動時,會直接到MJProxy中尋找timerTest方法,MJProxy中沒有timerTest方法,就會直接調用methodSignatureForSelector方法進行消息轉發,轉發給控制器后,最后調用控制器的timerTest方法。

③ NSProxy補充

如下代碼:

int main(int argc, char * argv[]) {
    @autoreleasepool {
        ViewController *vc = [[ViewController alloc] init];
        MJProxy *proxy = [MJProxy proxyWithTarget:vc]; //繼承于NSProxy的類
        MJProxy1 *proxy1 = [MJProxy1 proxyWithTarget:vc]; //繼承于NSObject的類
        
        NSLog(@"%d %d",
              [proxy isKindOfClass:[ViewController class]],
              [proxy1 isKindOfClass:[ViewController class]]);
        //打印:1 0
        return UIApplicationMain(argc, argv, nil, NSStringFromClass([AppDelegate class]));
    }
}

打印:1 0

按理說,左邊都不是ViewController類型或其子類,為什么第一個會打印1呢?

在GUNstep的NSProxy.m文件中,找到isKindOfClass方法的實現:

- (BOOL) isKindOfClass: (Class)aClass
{
  NSMethodSignature *sig;
  NSInvocation      *inv;
  BOOL          ret;

  sig = [self methodSignatureForSelector: _cmd];
  inv = [NSInvocation invocationWithMethodSignature: sig];
  [inv setSelector: _cmd];
  [inv setArgument: &aClass atIndex: 2];
  [self forwardInvocation: inv];
  [inv getReturnValue: &ret];
  return ret;
}

發現,這個方法直接進行了消息轉發,直接轉發給ViewController了,最后通過方法尋找流程找到的是ViewController的isKindOfClass方法,所以最后就是調用ViewController的isKindOfClass方法,所以上面會打印1。

三. GCD定時器

主線程的RunLoop承擔了大部分的工作,比如:UI界面的刷新、核心動畫的執行、點擊事件的處理。

1. NSTimer不準時的原因

NSTimer依賴于RunLoop,如果RunLoop的任務過于繁重,可能會導致NSTimer不準時。

如果RunLoop專門做NSTimer的事情的話,那么NSTimer是準時的 ,如果RunLoop除了在做NSTimer的事情外還做其他事情,那么會導致NSTimer不準時。
就比如說NSTimer是1s執行一次,可能它跑完第一圈發現才用了0.5s,這時候發現還沒到1s,所以NSTimer不會執行,但是跑第二圈的時候任務就多了可能就需要0.8s,跑完兩圈一共1.3s,這時候發現超過1s了,就會執行NSTimer,這時候NSTimer就不準了,晚了0.3s。

2. GCD定時器

而GCD的定時器會更加準時,因為GCD的定時器是直接和系統內核掛鉤的。

GCD定時器的簡單使用如下:

- (void)test
{
    
    //傳入主隊列,定時器就在主線程工作
//    dispatch_queue_t queue = dispatch_get_main_queue();
    
    //傳入非主隊列,定時器就在子線程工作
    dispatch_queue_t queue = dispatch_queue_create("timer", DISPATCH_QUEUE_SERIAL);
    
    // 創建定時器
    dispatch_source_t timer = dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0, queue);
    
    // 設置時間
    uint64_t start = 2.0; // 2秒后開始執行
    uint64_t interval = 1.0; // 每隔1秒執行
    /**
     定時器設置
     @param  定時器
     @param  什么時候開始
     @param  定時器延遲多久
     @param  每隔幾秒執行
     @param  允許多少誤差
     */
    //GCD要求傳入納秒,所以要用秒乘以NSEC_PER_SEC
    dispatch_source_set_timer(timer,
                              dispatch_time(DISPATCH_TIME_NOW, start * NSEC_PER_SEC),
                              interval * NSEC_PER_SEC, 0);
    
    // 設置block回調
//    dispatch_source_set_event_handler(timer, ^{
//        NSLog(@"1111");
//    });
    //設置函數回調
    dispatch_source_set_event_handler_f(timer, timerFire);
    
    // 啟動定時器
    dispatch_resume(timer);
    
    self.timer = timer;
}

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

GCD的定時器是和系統內核掛鉤的,所以就算界面上添加一個scrollView,滾動的時候就算RunLoop模式切換了,GCD定時器還會照常工作,因為GCD和RunLoop一點關系都沒有。

GCD雖然有使用create,但是在ARC模式下不用你管內存,因為GCD內部已經把內存管理好了。

3. GCD定時器的封裝

GCD的定時器比較準時,推薦使用,但是GCD的定時器使用起來比較麻煩,下面封裝一下GCD的定時器。

#import "MJTimer.h"

@implementation MJTimer

//只初始化一次
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);
    });
}

/**
 封裝GCD定時器
 
 @param task 任務block
 @param start 開始
 @param interval 間隔
 @param repeats 是否重復
 @param async 是否異步
 @return 返回定時器唯一標識
 */
+ (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_get_global_queue(0, 0) : 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);
    
    //對字典讀寫,加信號量鎖,保證創建任務和取消任務同時只有一個在做
    dispatch_semaphore_wait(semaphore_, DISPATCH_TIME_FOREVER);
    // 定時器的唯一標識
    NSString *name = [NSString stringWithFormat:@"%zd", timers_.count];
    // 存放到字典中
    timers_[name] = timer;
    dispatch_semaphore_signal(semaphore_);
    
    // 設置回調
    dispatch_source_set_event_handler(timer, ^{
        task();
        
        if (!repeats) { // 不重復的任務
            [self cancelTask:name];
        }
    });
    
    // 啟動定時器
    dispatch_resume(timer);
    
    return name;
}

/**
 封裝GCD定時器
 
 @param target 消息發送者
 @param selector 消息
 @param interval 間隔
 @param repeats 是否重復
 @param async 是否異步
 @return 返回定時器唯一標識
 */
+ (NSString *)execTask:(id)target selector:(SEL)selector start:(NSTimeInterval)start interval:(NSTimeInterval)interval repeats:(BOOL)repeats async:(BOOL)async
{
    if (!target || !selector) return nil;
    
    return [self execTask:^{
        if ([target respondsToSelector:selector]) {
//強制消除Xcode警告
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Warc-performSelector-leaks"
            [target performSelector:selector];
#pragma clang diagnostic pop
        }
    } start:start interval:interval repeats:repeats async:async];
}

/**
 取消任務
 
 @param name 根據唯一標識取消任務
 */
+ (void)cancelTask:(NSString *)name
{
    if (name.length == 0) return;
    
    //對字典讀寫,加信號量鎖,保證創建任務和取消任務同時只有一個在做
    dispatch_semaphore_wait(semaphore_, DISPATCH_TIME_FOREVER);
    
    //從字典中移除定時器
    dispatch_source_t timer = timers_[name];
    if (timer) {
        dispatch_source_cancel(timer);
        [timers_ removeObjectForKey:name];
    }

    dispatch_semaphore_signal(semaphore_);
}
@end

關于GCD定時器的封裝,可以看注釋。

上面代碼:#pragma clang diagnostic ignored 是用來強制消除Xcode警告的,后面跟的是警告唯一標識,關于警告唯一標識的查看方法,如下:

強制消除警告1.png
強制消除警告2.png

第一步:點擊build記錄
第二步:找到那個build
第三步:找到那個警告
第四步:找到警告唯一標識

面試題:

使用CADisplayLink、NSTimer有什么注意點?

  1. 循環引用的問題
  2. NSTimer不準時的問題

Demo地址:定時器、NSProxy

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。