RunLoop解決卡頓問題

漫步時光

人生就像RunLoop,不斷的循環、不斷的往復。當線程被殺掉,當生命結束,RunLoop就消失了,人生也就結束了。在有限的生命里,為何不讓自己像RunLoop一樣優雅的活著,享受每一個循環。

作為一個有強迫癥患者,在自己的app中容不下一絲雜質。最近了解了一下Runloop,在這里更大家分享下。

這里有一個模擬卡頓的Demo,包含卡頓與優化后的代碼,雖然工作中很少遇到卡頓,(有人會說就算卡頓也是因為代碼沒寫好,是的,我這里只是模擬一個卡頓的現象并通過Runloop解決,重點是Runloop解決問題的方式,不是這個代碼不該卡頓):

目錄
通過在storyboard中更改兩套代碼

一、卡頓分析

最根本的原因是RunLoop轉一圈的時間太長了,因為一次RunLoop循環需要解析24張大圖,很卡
既然一次RunLoop加載24張圖卡,那能不能一次循環加載1張呢?

二、分析RunLoop的運行機制

RunLoop:運行循環
-保證(線程)不退出
-負責監聽所有事件:時鐘、觸摸、網絡事件,沒有事件就睡眠
-每一條線程上面都有一個RunLoop,但是子線程的RunLoop默認不運行
RunLoop的事件處理:每當有時鐘、觸摸、網絡事件發生的時候,RunLoop蘇醒,執行一次循環,循環執行完畢馬上進入睡眠狀態。
可以猜想:能不能通過NSTimer來每間隔一定時間執行一個任務,這樣RunLoop每間隔一定時間就會蘇醒一次。每蘇醒一次就執行加載一張圖片。當圖片加載完成讓NSTimer釋放,當列表每滑動一次,讓NSTimer重新執行任務。

如下圖,RunLoop 想要跑起來,必須有 Mode 對象支持,而 Mode 里面必須有
(NSSet *)Source、 (NSArray *)Timer ,源和定時器。
至于另外一個類(NSArray *)observer是用于監聽 RunLoop 的狀態,因此不會激活RunLoop。


Runloop

observer是用于監聽 RunLoop 的狀態,我們就可以通過observer來監聽runloop的蘇醒

偽代碼
1、創建一個定時器:每間隔0.001s執行一個空方法來喚醒RunLoop
2、將加載圖片的方法裝入block,將block加入數組
3、監聽RunLoop的蘇醒,蘇醒回掉就執行一次就從數組中取出一個事件,執行完的事件從數組中刪除

說干就干。

三、代碼實現

可以先下載Demo

1、創建一個定時器:每間隔0.001s執行一個空方法來喚醒RunLoop(這里存在質疑,后面已經回答了質疑)

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

-(void)timerMethod{
    //啥都不干!!
}

(這里要感謝DreamTracer大神Q提的建議)
那這里添加定時器又是為了干什么呢?應不應該加呢?下面會講到。

2、將加載圖片的方法裝入block,將block加入數組

- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
    MyTableViewCell *cell = [[MyTableViewCell alloc] cellWithTableView:tableView withID:@"cell"];
    NSLog(@"current:%ld",(long)indexPath.row);
    ...
    cell.myImageView.image = nil;
    cell.secondLImage.image = nil;
    cell.thirdyLImage.image = nil;
    cell.fouthLImage.image = nil;
   ##### //添加事件
    //添加文字
    [self addTask:^{
        cell.myLabel.text = [NSString stringWithFormat:@"%zd - Drawing index is top priority", indexPath.row];
    }];
    [self addTask:^{
        cell.thirdLabel.text = [NSString stringWithFormat:@"%zd - Drawing large image is low priority. Should be distributed into different run loop passes.", indexPath.row];
    }];
    
    NSString *path1 = [[NSBundle mainBundle] pathForResource:@"spaceship" ofType:@"png"];
    
    //添加圖片
    [self addTask:^{
        UIImage *image1 = [UIImage imageWithContentsOfFile:path1];
        
        cell.myImageView.image = image1;
    }];
    
    [self addTask:^{
        UIImage *image2 = [UIImage imageWithContentsOfFile:path1];
        
        cell.secondLImage.image = image2;
        
    }];
    [self addTask:^{
        UIImage *image3 = [UIImage imageWithContentsOfFile:path1];
        
        cell.thirdyLImage.image = image3;
    }];
    [self addTask:^{
        UIImage *image4 = [UIImage imageWithContentsOfFile:path1];
        
        cell.fouthLImage.image = image4;
    }];
    return cell;
}
-(void)addTask:(RunloopBlock)task{
    if (!self.timer) {//這是優化
        self.timer = [NSTimer scheduledTimerWithTimeInterval:0.001 target:self selector:@selector(timerMethod) userInfo:nil repeats:YES];
    }
    
    //添加任務到數組!!
    [self.tasks addObject:task];
    
    
}

3、監聽RunLoop的蘇醒,蘇醒回掉就執行一次就從數組中取出一個事件,執行完的事件從數組中刪除

#pragma mark - <RunLoop>
//添加RunLoop觀察者!!  CoreFoundtion 里面 Ref (引用)指針!!
-(void)addRunloopObserver{
    //拿到當前的runloop
    CFRunLoopRef runloop = CFRunLoopGetCurrent();
    //定義一個context
    CFRunLoopObserverContext context = {
        0,
        (__bridge void *)(self),
        &CFRetain,
        &CFRelease,
        NULL,
    };
    
    //定義觀察
    static CFRunLoopObserverRef defaultModeObserver;
    //創建觀察者
    defaultModeObserver = CFRunLoopObserverCreate(NULL, kCFRunLoopBeforeWaiting, YES, 0, &Callback, &context);
    //添加當前runloop的觀察者!!
    CFRunLoopAddObserver(runloop, defaultModeObserver, kCFRunLoopCommonModes);
    //C 語言里面Create相關的函數!創建出來的指針!需要釋放
    CFRelease(defaultModeObserver);
}

static void Callback(CFRunLoopObserverRef observer, CFRunLoopActivity activity, void *info){
    NSLog(@"gemelaile ");
    //拿到控制器
    ViewController * vc = (__bridge ViewController *)info;
    if (vc.tasks.count == 0) {//任務執行完成就清掉timer
        [vc.timer invalidate];
        vc.timer = nil;
        return;
    }
    RunloopBlock task = vc.tasks.firstObject;
    task();
    //干掉第一個任務
    [vc.tasks removeObjectAtIndex:0];
}

如上代碼做到了監聽RunLoop的蘇醒,每次蘇醒都會回掉Callback方法

4 打開demo,分別引入

demo

ViewController(優化過的)與ViewControllerNo(未優化過的)運行,看是不是完美解決卡頓。(可以看看內存,cpu)

敲黑板

那么我們現在來講講為什么前面我們要添加定時器。
我們把加載圖片的事件放進數組中,每次runloop循環一次就執行一次事件。每次有拖動事件發生,runloop都會自動執行,runloop執行幾次呢,我不知道。所以為了安全起見,這里我加了個定時器。當然這里可以添加優化,例如滑動結束后初始化定時器,事件執行完就清理定時器。

完美解決卡頓問題,RunLoop是不是很強大。
是不是以為這篇文章就結束了,那你就太小看我了。
每次有拖動事件發生,runloop都會自動執行,runloop執行幾次呢,我不知道。最后我進入了深入的實驗了解。

真正的重點來了

    //創建runloop的即將處理 Source的觀察者
    static CFRunLoopObserverRef defaultModeObserver1;
    defaultModeObserver1 = CFRunLoopObserverCreate(NULL, kCFRunLoopBeforeSources, YES, 0, & sourceTodo, &context);
    //添加當前runloop的觀察者!!
    CFRunLoopAddObserver(runloop, defaultModeObserver1, kCFRunLoopCommonModes);
    //C 語言里面Create相關的函數!創建出來的指針!需要釋放
    CFRelease(defaultModeObserver1);


static void sourceTodo(CFRunLoopObserverRef observer, CFRunLoopActivity activity, void *info){
    NSLog(@"sourceTodo");
}

這樣每次有事件就會調用sourceTodo的事件
在callback也加上輸出log,同時把[vc.tasks removeObjectAtIndex:0];注銷掉,那樣vc.tasks就一直有事件,看看到底callBack會走多少次。這樣是不是就解決了我們的疑惑呢

static void Callback(CFRunLoopObserverRef observer, CFRunLoopActivity activity, void *info){
    
    
    //拿到控制器
    ViewController * vc = (__bridge ViewController *)info;
    NSLog(@"CallbackNoTask");//這里還沒有執行事件
    if (vc.tasks.count == 0) {
        return;
    }
    RunloopBlock task = vc.tasks.firstObject;
    task();
    //干掉第一個任務
   // [vc.tasks removeObjectAtIndex:0];
    NSLog(@"CallbackHasTask");//這里執行了事件
    
}

一個驚人的發現,callback一直停不下來。這是為什么?

RunloopBlock task = vc.tasks.firstObject;
task();

task()執行了什么,難道里面包含source或timer事件

+(void)addImage1With:(UITableViewCell *)cell{
    //第一張
    UIImageView *imageView = [[UIImageView alloc] initWithFrame:CGRectMake(5, 20, 85, 85)];
    imageView.tag = 1;
    NSString *path1 = [[NSBundle mainBundle] pathForResource:@"spaceship" ofType:@"png"];
    UIImage *image = [UIImage imageWithContentsOfFile:path1];
    imageView.contentMode = UIViewContentModeScaleAspectFit;
    imageView.image = image;
    [UIView transitionWithView:cell.contentView duration:0.3 options:(UIViewAnimationOptionCurveEaseInOut | UIViewAnimationOptionTransitionCrossDissolve) animations:^{
        [cell.contentView addSubview:imageView];
    } completion:nil];
}
[UIView transitionWithView:cell.contentView duration:0.3 options:(UIViewAnimationOptionCurveEaseInOut | UIViewAnimationOptionTransitionCrossDissolve) animations:^{
        [cell.contentView addSubview:imageView];
    } completion:nil];

這是一個timer事件
那這樣果斷去掉定時器。
把注銷的代碼[vc.tasks removeObjectAtIndex:0]打開,運行看看

事件執行完后,runloop還是會跑幾次就結束了。
功夫不負苦心人,終于算了解決了這個疑惑。再次感謝之前對我的文章提出質疑的大神們,是你們讓我有了動力來解決這些疑惑。

有沒有了解的欲望?。?!
完整的Demo

重點在runloop的用法,不在為什么會卡頓

下面是RunLoop的一些基礎知識,希望對你有幫助

RunLoop入門

一、簡介

首先,先象征性的講下RunLoop的概念
從字面上看,就可以看出就是兜圈圈,就是一個死循環嘛。

二、作用

1.保持程序運行
2.處理app的各種事件(比如觸摸,定時器等等)
3.節省CPU資源,提高性能。

三、枯燥知識

下面是關于RunLoop的一些使用簡述。也許有點枯燥,但是也是必須要知道的?。ㄇ煤诎錳ng),我盡量說的通俗易懂一點。

1.兩個API

首先要知道iOS里面有兩套API可以訪問和使用RunLoop:

Foundation

NSRunLoop

Core Foundation

CFRunLoopRef

上面兩套都可以使用,但是要知道CFRunLoopRef是用c語言寫的,是開源的,相比于NSRunLoop更加底層,而NSRunLoop其實是對CFRunLoopRef的一個簡單的封裝。便于使用而已。這樣說來,顯然CFRunLoopRef的性能要高一點。

2.RunLoop與線程(形象)

1.每條線程都有唯一的與之對應的RunLoop對象。
2.主線程的RunLoop已經創建好了,而子線程的需要手動創建。(也就是說子線程的RunLoop默認是關閉的,因為有時候開了個線程但卻沒有必要開一個RunLoop,不然反而浪費了資源。 )
3.RunLoop在第一次獲取時創建,在線程結束時銷毀。(這就相當于 線程是一個類,RunLoop是類里的實例變量,這樣便于理解)

3.獲取RunLoop對象

Foundation

[NSRunLoop currentRunLoop]; // 獲得當前線程的RunLoop對象
[NSRunLoop mainRunLoop]; // 獲得主線程的RunLoop對象

Core Foundation

CFRunLoopGetCurrent(); // 獲得當前線程的RunLoop對象
CFRunLoopGetMain(); // 獲得主線程的RunLoop對象 

4.RunLoop相關類

在Core Foundation中有RunLoop的五個類

    CFRunLoopRef
    CFRunLoopModeRef
    CFRunLoopSourceRef
    CFRunLoopTimerRef
    CFRunLoopObserverRef

這五個類的關系如下

五個類的關系

由圖中可以得出以下幾點:
1.CFRunLoopModeRef代表的是RunLoop的運行模式。
2.一個 RunLoop 包含若干個 Mode,每個 Mode 又包含若干個 Source/Timer/Observer。
3.每次調用 RunLoop 的主函數時,只能指定其中一個 Mode,這個Mode被稱作 CurrentMode。
4.如果需要切換 Mode,只能退出 Loop,再重新指定一個 Mode 進入。這樣做主要是為了分隔開不同組的 Source/Timer/Observer,讓其互不影響。

CFRunLoopModeRef

系統默認注冊了5個mode

kCFRunLoopDefaultMode //App的默認Mode,通常主線程是在這個Mode下運行
UITrackingRunLoopMode //界面跟蹤 Mode,用于 ScrollView 追蹤觸摸滑動,保證界面滑動時不受其他 Mode 影響
UIInitializationRunLoopMode // 在剛啟動 App 時第進入的第一個 Mode,啟動完成后就不再使用
GSEventReceiveRunLoopMode // 接受系統事件的內部 Mode,通常用不到
kCFRunLoopCommonModes //這是一個占位用的Mode,不是一種真正的Mode

至于CFRunLoopModeRef的使用我會在 下面的實驗三timer的使用中 詳細說到。

四、實驗講解

這里開始之前,希望您跟著新建一個工程。實操最清晰。
一、main函數的實驗
再來做個試驗:將main的代碼添加一個輸出NSLog,如下

int main(int argc, char * argv[]) {
    @autoreleasepool {
        int res = UIApplicationMain(argc, argv, nil, NSStringFromClass([AppDelegate class]));
        NSLog(@"-----");
        return res;

    }
}

你猜會輸出 “-----” 嗎?答案是否定的,你會發現程序始終不會到NSLog(@"-----");這一行來。這就說明了程序一直在運行著。其實這都是RunLoop的功勞,它的其中一個功能就是保持程序的持續運行。有了RunLoop,main里面相當于是這樣的代碼(偽代碼):

BOOL running = YES;
do {
   // 執行各種操作
} while (running);
return 0;

程序是始終在while里面的,是一個死循環。
說到這里你肯定又會疑惑,RunLoop是什么時候創建的。其實在UIApplicationMain(argc, argv, nil, NSStringFromClass([AppDelegate class]))這個函數的內部就已經啟動了一個RunLoop,所以函數一直沒有返回,這才使得程序保持運行。
(注意:這個默認啟動的RunLoop是和主線程相關的!!!)

二、NSTimer的使用

在項目中用的NSTimer其實也和RunLoop有關系,下面我們來做個實驗

實驗一 scheduledTimer方法

新建一個工程,在ViewController中添加一個UIButton,增加button的響應以及timerTest方法,代碼如下

- (IBAction)ButtonDidClick:(id)sender {
    [NSTimer scheduledTimerWithTimeInterval:1.0 target:self selector:@selector(timerTest) userInfo:nil repeats:YES];
}

- (void)timerTest
{
    NSLog(@"timerTest----");
}

點擊button可以看到輸出臺每隔一秒鐘就打印"timerTest----"。

實驗二 timerWithTime方法

代碼如下:

- (IBAction)ButtonDidClick:(id)sender {
    NSTimer *timer = [NSTimer timerWithTimeInterval:1.0 target:self selector:@selector(timerTest) userInfo:nil repeats:YES];
}

- (void)timerTest
{
    NSLog(@"timerTest----");
}

但是實驗結果是,點擊button后沒有反應。為什么呢?
噢~原來是少加了一句話,添加后的代碼如下:

- (IBAction)ButtonDidClick:(id)sender {
    NSTimer *timer = [NSTimer timerWithTimeInterval:1.0 target:self selector:@selector(timerTest) userInfo:nil repeats:YES];
    [[NSRunLoop currentRunLoop] addTimer:timer forMode:NSDefaultRunLoopMode];
}

- (void)timerTest
{
    NSLog(@"timerTest----");
}

可是,為什么實驗二比實驗一要多加一句話呢?解:那是因為scheduledTimer方法會自動添加到當前的runloop里面去,而且runloop的運行模式kCFRunLoopDefaultMode,也就是說實驗一已經將timer自動加入到了一個運行模式為kCFRunLoopDefaultMode的runloop中。

實驗三 有scrollView的情況下使用Timer

首先,按鈕響應以及timerTest的方法如下:

- (IBAction)ButtonDidClick:(id)sender {
    NSTimer *timer = [NSTimer timerWithTimeInterval:1.0 target:self selector:@selector(timerTest) userInfo:nil repeats:YES];
    [[NSRunLoop currentRunLoop] addTimer:timer forMode:NSDefaultRunLoopMode];
}

- (void)timerTest
{
    NSLog(@"timerTest----");
}

然后在vc中加一個textView,run起來,

然后點擊按鈕,隨后滑動textView,根據打印結果可以看出滑動textView的時候是不打印的,奇怪吧。其實說到底還是RunLoop搞的鬼??梢钥吹?,我們把timer加到了NSDefaultRunLoopMode的runLoop中,而在滑動textview的時候,RunLoop就切換到UITrackingRunLoopMode模式,而上面有提到說:在每次調用 RunLoop 的主函數時,只能指定其中一個 Mode,這個Mode被稱作 CurrentMode。 所以定時器就不起作用了。
現在可以思考一下解決方法了?。ㄇ煤诎錳ng)
提示一下,問題出在了模式上面,是不是修改一下模式就好了呢。

解決方法:
上面有提到過五個mode

    kCFRunLoopDefaultMode //App的默認Mode,通常主線程是在這個Mode下運行
    UITrackingRunLoopMode //界面跟蹤 Mode,用于 ScrollView 追蹤觸摸滑動,保證界面滑動時不受其他 Mode 影響
    UIInitializationRunLoopMode // 在剛啟動 App 時第進入的第一個 Mode,啟動完成后就不再使用
    GSEventReceiveRunLoopMode // 接受系統事件的內部 Mode,通常用不到
    kCFRunLoopCommonModes //這是一個占位用的Mode,不是一種真正的Mode

其實如果把mode改為kCFRunLoopCommonModes的話就可以既支持kCFRunLoopDefaultMode又支持UITrackingRunLoopMode了。
修改如下:
修改mode類型

- (IBAction)ButtonDidClick:(id)sender {
    NSTimer *timer = [NSTimer timerWithTimeInterval:1.0 target:self selector:@selector(timerTest) userInfo:nil repeats:YES];
    [[NSRunLoop currentRunLoop] addTimer:timer forMode:NSRunLoopCommonModes];
}

然后run發現就算滾動textView也不會影響打印。

寫在最后:
希望這篇文章對您有幫助。當然如果您發現有可以優化的地方,希望您能慷慨的提出來。最后祝您工作愉快!

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

推薦閱讀更多精彩內容