概要
RunLoop在iOS開發中的應用范圍并沒有像runtime 那樣廣泛,我們通過CFRuntime的源代碼可知runloop跟線程的是密不可分的,一個線程一定會創建一個對應的runloop,只是主線程創建就自動run了,而子線程只會創建不會自動run。蘋果線程管理 Thread Management也說了在線程中利用runloop,
此外,runloop并不是一個簡單的do-while,作為OSX/iOS系統中Event Loop表現,runloop需要處理消息事件,在沒有消息的時候休眠,有消息事件的時候立刻喚醒。
綜上所述,從我個人所接觸到知識面runloop一是處理子線程運行,二是根據runloop的不同的activities來處理問題。當然希望通過我這塊磚頭,引出同學們runloop應用的好玉來。
1.CFRunLoopSourceRef 事件源
在下面代碼中,通過自定義子線程thread,運行結果可知hello China是不會被打印的,子線程在打印完hello world 就exit了。
{
NSThread *thread = [[NSThread alloc] initWithTarget:self selector:@selector(threadFun) object:nil];
[thread start];
self.thread = thread;
[self performSelector:@selector(selectorFun) onThread:thread withObject:nil waitUntilDone:NO];
NSLog(@"hello Thread");
}
- (void)threadFun {
NSLog(@"hello world");
// _pthread_exit
}
- (void)selectorFun {
NSLog(@"hello China");
}
獲取上面代碼的堆棧可以看到子線程瞬間生命就結束了。類似在threadFun函數塊結束的前面添加了_pthread_exit
frame #0: 0x000000010232f2b0 CoreFoundation`__CFFinalizeRunLoop
frame #1: 0x000000010232f264 CoreFoundation`__CFTSDFinalize + 100
frame #2: 0x0000000104e9f39f libsystem_pthread.dylib`_pthread_tsd_cleanup + 544
frame #3: 0x0000000104e9f0d9 libsystem_pthread.dylib`_pthread_exit + 152
frame #4: 0x0000000104e9fc38 libsystem_pthread.dylib`pthread_exit + 30
frame #5: 0x0000000101a36f1e Foundation`+[NSThread exit] + 11
frame #6: 0x0000000101ab713f Foundation`__NSThread__start__ + 1218
frame #7: 0x0000000104e9d93b libsystem_pthread.dylib`_pthread_body + 180
frame #8: 0x0000000104e9d887 libsystem_pthread.dylib`_pthread_start + 286
frame #9: 0x0000000104e9d08d libsystem_pthread.dylib`thread_start + 13
根據蘋果線程管理的說法可以利用把線程放入runloop中,我們知道子線程的runloop并沒有自動開啟,需要我們手動開啟,蘋果也提供代碼示例:
- (void)threadMainRoutine
{
BOOL moreWorkToDo = YES;
BOOL exitNow = NO;
NSRunLoop* runLoop = [NSRunLoop currentRunLoop];
// Add the exitNow BOOL to the thread dictionary.
NSMutableDictionary* threadDict = [[NSThread currentThread] threadDictionary];
[threadDict setValue:[NSNumber numberWithBool:exitNow] forKey:@"ThreadShouldExitNow"];
// Install an input source.
[self myInstallCustomInputSource];
while (moreWorkToDo && !exitNow)
{
// Do one chunk of a larger body of work here.
// Change the value of the moreWorkToDo Boolean when done.
// Run the run loop but timeout immediately if the input source isn't waiting to fire.
[runLoop runUntilDate:[NSDate date]];
// Check to see if an input source handler changed the exitNow value.
exitNow = [[threadDict valueForKey:@"ThreadShouldExitNow"] boolValue];
}
}
因此我們可以把我們上面的代碼修改為,程序可以打印出來hello China
- (void)threadFun {
NSLog(@"hello world");
NSRunLoop *runLoop = [NSRunLoop currentRunLoop];
[runLoop runUntilDate:[NSDate distantFuture]];
// _pthread_exit
}
可以我們把代碼修改成在界面添加一個按鈕點擊事件,點擊事件由我們的子線程出來,同時我們刪除我們的線程的selectorFun函數邏輯,發現我們觸發按鈕的點擊事件并不會打印doSomething。
{
UIButton *btn = [[UIButton alloc]initWithFrame:CGRectMake(0, 80, 50, 50)];
btn.backgroundColor = [UIColor redColor];
[self.view addSubview:btn];
[btn addTarget:self action:@selector(clicked) forControlEvents:UIControlEventTouchUpInside];
NSThread *thread = [[NSThread alloc] initWithTarget:self selector:@selector(threadFun) object:nil];
[thread start];
self.thread = thread;
}
- (void)threadFun {
NSLog(@"hello world");
NSRunLoop *runLoop = [NSRunLoop currentRunLoop];
[runLoop runUntilDate:[NSDate distantFuture]];
// _pthread_exit
}
- (void)clicked{
[self performSelector:@selector(doSomething) onThread:self.thread withObject:nil waitUntilDone:NO];
}
- (void)doSomething{
NSLog(@"doSomething");
}
因為當前runloop運行的model沒有modeItem,run運行的前提條件必須保證當前model是有item( Source/Timer,二者之一,實際是不需要Observer)將代碼修改為下面 :
- (void)threadFun {
NSLog(@"hello world");
NSRunLoop *runLoop = [NSRunLoop currentRunLoop];
[runLoop addPort:[NSMachPort port] forMode:NSDefaultRunLoopMode];
[runLoop runUntilDate:[NSDate distantFuture]];
}
RunLoop只處理兩種源:輸入源、時間源。而輸入源又可以分為:NSPort/自定義源/performSelector,我們常用搭到的performSelector方法有:
// 主線程
performSelectorOnMainThread:withObject:waitUntilDone:
performSelectorOnMainThread:withObject:waitUntilDone:modes:
/// 指定線程
performSelector:onThread:withObject:waitUntilDone:
performSelector:onThread:withObject:waitUntilDone:modes:
/// 針對當前線程
performSelector:withObject:afterDelay:
performSelector:withObject:afterDelay:inModes:
/// 取消,在當前線程,和上面兩個方法對應
cancelPreviousPerformRequestsWithTarget:
cancelPreviousPerformRequestsWithTarget:selector:object:
而下面這些不是事件源的,相當于是[self xxx]調用
- (id)performSelector:(SEL)aSelector;
- (id)performSelector:(SEL)aSelector withObject:(id)object;
- (id)performSelector:(SEL)aSelector withObject:(id)object1 withObject:(id)object2;
run運行函數主要以下3個
- (void)run;
- (void)runUntilDate:(NSDate *)limitDate;
- (BOOL)runMode:(NSString *)mode beforeDate:(NSDate *)limitDate;
第一個run循環一旦開啟,就關閉不了,并且之后的代碼就無法執行。api文檔中提到:如果沒有輸入源和定時源加入到runloop中,runloop就馬上退出,否則通過頻繁調用-runMode:beforeDate:方法來讓runloop運行在NSDefaultRunLoopMode模式下。
第二個run運行在NSDefaultRunLoopMode模式,有超時時間限制。它實際上也是不斷調用-runMode:beforeDate:方法來讓runloop運行在NSDefaultRunLoopMode模式下,直到到達超時時間。調用CFRunLoopStop(runloopRef)無法停止Run Loop的運行,這個方法只會結束當前-runMode:beforeDate:的調用,之后的runMode:beforeDate:該調用的還是會繼續。直到timeout。對應
CFRunLoopRunInMode(kCFRunLoopDefaultMode,limiteDate,false)
第三個run比第二種方法是可以指定運行模式,只執行一次,執行完就退出。可以用CFRunLoopStop(runloopRef)退出runloop。api文檔里面提到:在第一個input source(非timer)被處理或到達limitDate之后runloop退出,對應
CFRunLoopRunInMode(mode,limiteDate,true)
1.1 子線程常駐
給當前子線程的runbloop的mode 添加事件源來實現線程常駐。所有的關于這個的都會拿AF2.X的代碼說明這個常駐的案例,如果同學開發iOS稍微有點年長的話或者古董代碼的都會用到網絡第三方庫ASIHTTPRequest,也用到利用CFRunLoopAddSource 讓當前網絡線程常駐。
+ (NSThread *)threadForRequest:(ASIHTTPRequest *)request
{
if (networkThread == nil) {
@synchronized(self) {
if (networkThread == nil) {
networkThread = [[NSThread alloc] initWithTarget:self selector:@selector(runRequests) object:nil];
[networkThread start];
}
}
}
return networkThread;
}
+ (void)runRequests
{
// Should keep the runloop from exiting
CFRunLoopSourceContext context = {0, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL};
CFRunLoopSourceRef source = CFRunLoopSourceCreate(kCFAllocatorDefault, 0, &context);
CFRunLoopAddSource(CFRunLoopGetCurrent(), source, kCFRunLoopDefaultMode);
BOOL runAlways = YES; // Introduced to cheat Static Analyzer
while (runAlways) {
NSAutoreleasePool *pool = [[NSAutoreleasePool alloc] init];
CFRunLoopRun();
[pool release];
}
// Should never be called, but anyway
CFRunLoopRemoveSource(CFRunLoopGetCurrent(), source, kCFRunLoopDefaultMode);
CFRelease(source);
}
1.2 程序crash 彈框提示
這個是算我真正接觸到runloop的,當用戶正在操作我們的APP的時候數據發生異常,程序會瞬間閃退,實際上從產品角度老說是一種非常不好的體驗,而對碼農來說也根本無法知道當前程序crash的堆棧信息,通過利用runloop的線程常駐方式,當程序發生異常的時候,通過異常捕獲然后彈出提示框 而不是立馬閃退,同時也可以讓用戶上傳crash日志,早期我還是看到APP在使用這樣的技術,現在crash收集機制越來越完善,目前來說幾乎有這么使用的了。
- (void)alertView:(UIAlertView *)anAlertView clickedButtonAtIndex:(NSInteger)anIndex
{
if (anIndex == 0)
{
dismissed = YES;
}else if (anIndex==1) {
NSLog(@"ssssssss");
}
}
- (void)handleException:(NSException *)exception
{
[self validateAndSaveCriticalApplicationData];
UIAlertView *alert =
[[[UIAlertView alloc]
initWithTitle:NSLocalizedString(@"抱歉,程序出現了異常", nil)
message:[NSString stringWithFormat:NSLocalizedString(
@"如果點擊繼續,程序有可能會出現其他的問題,建議您還是點擊退出按鈕并重新打開\n\n"
@"異常原因如下:\n%@\n%@", nil),
[exception reason],
[[exception userInfo] objectForKey:UncaughtExceptionHandlerAddressesKey]]
delegate:self
cancelButtonTitle:NSLocalizedString(@"退出", nil)
otherButtonTitles:NSLocalizedString(@"繼續", nil), nil]
autorelease];
[alert show];
CFRunLoopRef runLoop = CFRunLoopGetCurrent();
CFArrayRef allModes = CFRunLoopCopyAllModes(runLoop);
while (!dismissed)
{
for (NSString *mode in (NSArray *)allModes)
{
CFRunLoopRunInMode((CFStringRef)mode, 0.001, false);
}
}
CFRelease(allModes);
NSSetUncaughtExceptionHandler(NULL);
signal(SIGABRT, SIG_DFL);
signal(SIGILL, SIG_DFL);
signal(SIGSEGV, SIG_DFL);
signal(SIGFPE, SIG_DFL);
signal(SIGBUS, SIG_DFL);
signal(SIGPIPE, SIG_DFL);
if ([[exception name] isEqual:UncaughtExceptionHandlerSignalExceptionName])
{
kill(getpid(), [[[exception userInfo] objectForKey:UncaughtExceptionHandlerSignalKey] intValue]);
}
else
{
[exception raise];
}
}
2 CFRunLoopObserverRef
iOS系統會監聽主線程中runloop的的進入/休眠、退出的activities 來處理autoreleasepool,也是同學們長討論的自動釋放池在什么時候釋放的問題。
<CFRunLoopObserver 0x7fb064418b50 [0x10e005a40]>{valid = Yes, activities = 0x1, repeats = Yes, order = -2147483647, callout = _wrapRunLoopWithAutoreleasePoolHandler (0x10e18c4c2), context = <CFArray 0x7fb0644189e0 [0x10e005a40]>{type = mutable-small, count = 0, values = ()}}
<CFRunLoopObserver 0x7fb064418bf0 [0x10e005a40]>{valid = Yes, activities = 0xa0, repeats = Yes, order = 2147483647, callout = _wrapRunLoopWithAutoreleasePoolHandler (0x10e18c4c2), context = <CFArray 0x7fb0644189e0 [0x10e005a40]>{type = mutable-small, count = 0, values = ()}}
2.1 CFRunLoopObserverRef函數
通過CFRunLoopObserverRef 我們可以監測當前runloop的運行狀態引用YYKit的寫法:其中優先級設置為最小的32位-0x7fffffff 和最大的32位0x7fffffff
static void YYRunloopAutoreleasePoolSetup() {
CFRunLoopRef runloop = CFRunLoopGetCurrent();
CFRunLoopObserverRef pushObserver;
pushObserver = CFRunLoopObserverCreate(CFAllocatorGetDefault(), kCFRunLoopEntry,
true, // repeat
-0x7FFFFFFF, // before other observers
YYRunLoopAutoreleasePoolObserverCallBack, NULL);
CFRunLoopAddObserver(runloop, pushObserver, kCFRunLoopCommonModes);
CFRelease(pushObserver);
CFRunLoopObserverRef popObserver;
popObserver = CFRunLoopObserverCreate(CFAllocatorGetDefault(), kCFRunLoopBeforeWaiting | kCFRunLoopExit,
true, // repeat
0x7FFFFFFF, // after other observers
YYRunLoopAutoreleasePoolObserverCallBack, NULL);
CFRunLoopAddObserver(runloop, popObserver, kCFRunLoopCommonModes);
CFRelease(popObserver);
}
另外一種是block方式
// 創建observer
CFRunLoopObserverRef observer = CFRunLoopObserverCreateWithHandler(CFAllocatorGetDefault(), kCFRunLoopAllActivities, YES, 0, ^(CFRunLoopObserverRef observer, CFRunLoopActivity activity) {
NSLog(@"----監聽到RunLoop狀態發生改變---%zd", activity);
});
// 添加觀察者:監聽RunLoop的狀態
CFRunLoopAddObserver(CFRunLoopGetCurrent(), observer, kCFRunLoopDefaultMode);
// 釋放Observer
CFRelease(observer);
2.2 利用空閑時間緩存數據
UITableView+FDTemplateLayoutCell的作者sunnyxx曾在優化UITableViewCell高度計算的那些事提到利用runloop來緩存cell的高度。
作者所說的代碼如下:
但是這段代碼在1.4版本之后就被去掉了,sunnyxx解釋是:
2.3 檢測UI卡頓
第一種方法通過子線程監測主線程的 runLoop,判斷兩個狀態區域之間的耗時是否達到一定閾值。ANREye就是在子線程設置flag 標記為YES, 然后在主線程中將flag設置為NO。利用子線程時闕值時長,判斷標志位是否成功設置成NO。
private class AppPingThread: Thread {
func start(threshold:Double, handler: @escaping AppPingThreadCallBack) {
self.handler = handler
self.threshold = threshold
self.start()
}
override func main() {
while self.isCancelled == false {
self.isMainThreadBlock = true
DispatchQueue.main.async {
self.isMainThreadBlock = false
self.semaphore.signal()
}
Thread.sleep(forTimeInterval: self.threshold)
if self.isMainThreadBlock {
self.handler?()
}
self.semaphore.wait(timeout: DispatchTime.distantFuture)
}
}
private let semaphore = DispatchSemaphore(value: 0)
private var isMainThreadBlock = false
private var threshold: Double = 0.4
fileprivate var handler: (() -> Void)?
}
NSRunLoop調用方法主要就是在kCFRunLoopBeforeSources和kCFRunLoopBeforeWaiting之間,還有kCFRunLoopAfterWaiting之后,也就是如果我們發現這兩個時間內耗時太長,那么就可以判定出此時主線程卡頓,下面的代碼片段來源iOS實時卡頓監控
static void runLoopObserverCallBack(CFRunLoopObserverRef observer, CFRunLoopActivity activity, void *info)
{
MyClass *object = (__bridge MyClass*)info;
// 記錄狀態值
object->activity = activity;
// 發送信號
dispatch_semaphore_t semaphore = moniotr->semaphore;
dispatch_semaphore_signal(semaphore);
}
- (void)registerObserver
{
CFRunLoopObserverContext context = {0,(__bridge void*)self,NULL,NULL};
CFRunLoopObserverRef observer = CFRunLoopObserverCreate(kCFAllocatorDefault,
kCFRunLoopAllActivities,
YES,
0,
&runLoopObserverCallBack,
&context);
CFRunLoopAddObserver(CFRunLoopGetMain(), observer, kCFRunLoopCommonModes);
// 創建信號
semaphore = dispatch_semaphore_create(0);
// 在子線程監控時長
dispatch_async(dispatch_get_global_queue(0, 0), ^{
while (YES)
{
// 假定連續5次超時50ms認為卡頓(當然也包含了單次超時250ms)
long st = dispatch_semaphore_wait(semaphore, dispatch_time(DISPATCH_TIME_NOW, 50*NSEC_PER_MSEC));
if (st != 0)
{
if (activity==kCFRunLoopBeforeSources || activity==kCFRunLoopAfterWaiting)
{
if (++timeoutCount < 5)
continue;
NSLog(@"好像有點兒卡哦");
}
}
timeoutCount = 0;
}
});
第二種方式就是FPS監控,App 刷新率應該當努力保持在 60fps,通過CADisplayLink記錄兩次刷新時間間隔,就可以計算出當前的 FPS。
_link = [CADisplayLink displayLinkWithTarget:self selector:@selector(displayLinkTick:)];
[_link addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSRunLoopCommonModes];
- (void)displayLinkTick:(CADisplayLink *)link {
if (lastTime == 0) {
lastTime = link.timestamp;
return;
}
count++;
NSTimeInterval interval = link.timestamp - lastTime;
if (interval < 1) return;
lastTime = link.timestamp;
float fps = count / interval;
count = 0;
NSString *text = [NSString stringWithFormat:@"%d FPS",(int)round(fps)];
3 CFRunLoopModeRef
每次啟動RunLoop時,只能指定其中一個 Mode,這個就是CurrentMode。要切換 Mode,只能退出 Loop,再重新指定一個 Mode 進入。系統默認注冊了5個mode,以下兩個是比較常用的:
1.kCFRunLoopDefaultMode (NSDefaultRunLoopMode),默認模式
2.UITrackingRunLoopMode, scrollview滑動時就是處于這個模式下。保證界面滑動時不受其他mode影響。
CFRunLoop對外暴露的管理 Mode 接口只有下面2個:
CFRunLoopAddCommonMode(CFRunLoopRef runloop, CFStringRef modeName);
CFRunLoopRunInMode(CFStringRef modeName, ...);
struct __CFRunLoop {
CFMutableSetRef _commonModes; // Set
CFMutableSetRef _commonModeItems; // Set<Source/Observer/Timer>
CFRunLoopModeRef _currentMode; // Current Runloop Mode
CFMutableSetRef _modes; // Set
...
};
3.1 解決NSTime和scrollView糾葛
如果利用scrollView類型的做自動廣告滾動條 需要把定時器加入當前runloop的模式NSRunLoopCommonModes
- (void)scrollViewWillBeginDragging:(UIScrollView *)scrollView
{
if (self.autoScroll) {
[self invalidateTimer];
}
}
- (void)scrollViewDidEndDragging:(UIScrollView *)scrollView willDecelerate:(BOOL)decelerate
{
if (self.autoScroll) {
[self setupTimer];
}
}
(void)setupTimer
{
[self invalidateTimer];
NSTimer *timer = [NSTimer scheduledTimerWithTimeInterval:self.autoScrollTimeInterval target:self selector:@selector(automaticScroll) userInfo:nil repeats:YES];
_timer = timer;
[[NSRunLoop mainRunLoop] addTimer:timer forMode:NSRunLoopCommonModes];
}
- (void)invalidateTimer
{
[_timer invalidate];
_timer = nil;
}
3.2 RunLoopCommonModes
一個mode可以標記為common屬性(用CFRunLoopAddCommonMode函數),然后它就會保存在_commonModes。主線程有kCFRunLoopDefaultMode 和 UITrackingRunLoopMode 都已經是CommonModes了,而子線程只有kCFRunLoopDefaultMode。
_commonModeItems里面存放的source, observer, timer等,在每次runLoop運行的時候都會被同步到具有Common標記的Modes里。比如這樣:[[NSRunLoop currentRunLoop] addTimer:_timer forMode:NSRunLoopCommonModes];就是把timer放到commonItem里。
kCFRunLoopCommonModes是一個占位用的mode,它不是真正意義上的mode。如果要在線程中開啟runloop,這樣寫是不對的:
[[NSRunLoop currentRunLoop] runMode:NSRunLoopCommonModes beforeDate:[NSDate distantFuture]];
上面的runMode beforeDate回調用CFrunloop的CFRunLoopRunSpecific函數,函數中回根據當前的name去查找當前的運營的mode,可是根本就不會存在CommonMode的。
3.3 TableView中實現平滑滾動延遲加載圖片
順帶提一下,這個我在開發中沒有用到。是利用CFRunLoopMode的特性,可以將圖片的加載放到NSDefaultRunLoopMode的mode里,這樣在滾動UITrackingRunLoopMode這個mode時不會被加載而影響到。這個主要受到Github的RunLoopWorkDistribution影響,
其主要代碼片段如下:
- (instancetype)init
{
if ((self = [super init])) {
_maximumQueueLength = 30;
_tasks = [NSMutableArray array];
_tasksKeys = [NSMutableArray array];
_timer = [NSTimer scheduledTimerWithTimeInterval:0.1 target:self selector:@selector(_timerFiredMethod:) userInfo:nil repeats:YES];
}
return self;
}
static void _registerObserver(CFOptionFlags activities, CFRunLoopObserverRef observer, CFIndex order, CFStringRef mode, void *info, CFRunLoopObserverCallBack callback) {
CFRunLoopRef runLoop = CFRunLoopGetCurrent();
CFRunLoopObserverContext context = {
0,
info,
&CFRetain,
&CFRelease,
NULL
};
observer = CFRunLoopObserverCreate( NULL,
activities,
YES,
order,
callback,
&context);
CFRunLoopAddObserver(runLoop, observer, mode);
CFRelease(observer);
}
static void _runLoopWorkDistributionCallback(CFRunLoopObserverRef observer, CFRunLoopActivity activity, void *info)
{
DWURunLoopWorkDistribution *runLoopWorkDistribution = (__bridge DWURunLoopWorkDistribution *)info;
if (runLoopWorkDistribution.tasks.count == 0) {
return;
}
BOOL result = NO;
while (result == NO && runLoopWorkDistribution.tasks.count) {
DWURunLoopWorkDistributionUnit unit = runLoopWorkDistribution.tasks.firstObject;
result = unit();
[runLoopWorkDistribution.tasks removeObjectAtIndex:0];
[runLoopWorkDistribution.tasksKeys removeObjectAtIndex:0];
}
}