RunLoop — pętla zdarzeń w iOS zaimplementowana przez obiekty CFRunLoop (Core Foundation) i NSRunLoop (Foundation). To mechanizm, który oczekuje na zdarzenia (dotknięcia, timery, źródła wejścia, powiadomienia) i przekazuje je do odpowiednich handlerów w wątku. Każdy wątek w iOS ma maksymalnie jeden RunLoop, ale jest on automatycznie tworzony tylko dla Main Thread. Według dokumentacji Apple CFRunLoop, RunLoop jest krytyczny dla działania timerów, animacji i monitorowania źródeł w wątkach tła.
Najważniejsze
RunLoop — to obiekt infrastruktury Core Foundation, który organizuje przetwarzanie zdarzeń w wątku. W istocie jest to nieskończona pętla (while true), która oczekuje na nadejście zdarzeń (sources) i przekazuje je do handlerów. Gdy nie ma zdarzeń, RunLoop usypia wątek (sleep), oszczędzając energię baterii. Po nadejściu zdarzenia wątek jest wybudzany, przetwarza je i ponownie zasypia. RunLoop istnieje tylko w iOS/macOS (XNU + Core Foundation) — w Android jego rolę pełni Looper.
Każdy wątek ma co najwyżej jeden RunLoop, który jest tworzony leniwie (lazy) przy pierwszym dostępie. Dla Main Thread RunLoop jest tworzony automatycznie podczas uruchamiania aplikacji. Dla wątków tła RunLoop nie jest tworzony, dopóki nie zostanie wywołany CFRunLoopGetCurrent() lub RunLoop.current. Główny RunLoop aplikacji odpowiada za przetwarzanie zdarzeń dotykowych, renderowanie ekranu, wykonywanie bloków DispatchQueue.main i obsługę warstw Core Animation.
RunLoop nie jest wątkiem — to mechanizm wewnątrz wątku. Wątek może istnieć bez RunLoop (jeśli wykonuje synchroniczne zadanie i kończy się), ale RunLoop nie może istnieć bez wątku. Gdy wątek z aktywnym RunLoop nie ma zdarzeń, nie blokuje CPU, lecz znajduje się w stanie oczekiwania (waiting) — to kluczowa ró ica w porównaniu z busy-wait, który zużywa 100% CPU.
RunLoop przetwarza dwa typy źródeł zdarzeń: Input Sources (źródła wejścia) i Timer Sources (timery). Input Sources dostarczają asynchroniczne zdarzenia: dotknięcia, ruchy myszy, dane z gniazda, wiadomości od innych wątków (performSelector:onThread:). Timer Sources dostarczają synchroniczne zdarzenia zgodnie z harmonogramem: NSTimer, CADisplayLink. Istnieją ró ież Observers — punkty wejścia do monitorowania stanu RunLoop.
Cykl RunLoop składa się z kolejnych faz: wejście w tryb (kCFRunLoopEntry), przetwarzanie timerów (kCFRunLoopBeforeTimers), przetwarzanie źródeł wejścia (kCFRunLoopBeforeSources), przetwarzanie źródeł (kCFRunLoopAfterWaiting), oczekiwanie (sleep), wyjście z trybu (kCFRunLoopExit). Jeśli w bieżącej iteracji nie przetworzono żadnego zdarzenia, RunLoop usypia wątek na nieokreślony czas, aż do obudzenia przez nowe zdarzenie.
import Foundation
// Demonstracja faz RunLoop przez Observer
func observeRunLoopActivities() {
let observer = CFRunLoopObserverCreateWithHandler(
nil,
CFOptionFlags([[.entry, .beforeTimers, .beforeSources,
.afterWaiting, .exit]]),
true, // repeats
0 // priority
) { observer, activity in
switch activity {
case .entry:
print("Entry — RunLoop aktywowany")
case .beforeTimers:
print("BeforeTimers — przetwarzanie timerów")
case .beforeSources:
print("BeforeSources — przetwarzanie źródeł")
case .afterWaiting:
print("AfterWaiting — przebudzenie po sleep")
case .exit:
print("Exit — RunLoop zakończony")
default:
break
}
}
CFRunLoopAddObserver(
CFRunLoopGetCurrent(),
observer,
.commonModes
)
}
// Przykład: RunLoop przetwarza timer na głównym wątku
func timerOnMainRunLoop() {
Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { timer in
print("Tick: \(Date())")
}
// RunLoop.current.run() na Main Thread jest wywoływany przez UIApplicationMain
// automatycznie — nie trzeba uruchamiać ręcznie
RunLoop.current.run() // To wywołanie nie powróci na Main Thread
}
Przykład observeRunLoopActivities rejestruje Observer w głównym RunLoop, który loguje każdą fazę cyklu. Jest to przydatne do debugowania: jeśli widzisz długi odstęp między .beforeTimers a .afterWaiting, oznacza to, że RunLoop jest zablokowany przez operację na Main Thread. timerOnMainRunLoop pokazuje, jak NSTimer automatycznie działa na głównym RunLoop — podczas tworzenia Timer.scheduledTimer dodaje timer do bieżącego RunLoop domyślnie (.default mode).
Android Looper — odpowiednik RunLoop. Looper.prepare() tworzy kolejkę komunikatów (MessageQueue) w wątku, Looper.loop() uruchamia nieskończoną pętlę przetwarzania. Handler wysyła komunikaty i Runnable do tej kolejki. Główna ró ica: RunLoop obsługuje tryby (modes), a Android Looper — nie. Looper przetwarza wszystkie komunikaty bez filtrowania według trybu, co czyni go prostszym, ale mniej elastycznym w scenariuszach z priorytetami (np. przewijanie w iOS jest przetwarzane w trybie .tracking oddzielnie od innych zdarzeń).
RunLoop Mode — to zbiór źródeł, timerów i obserwatorów aktywnych w danym momencie. Tryby pozwalają izolować przetwarzanie zdarzeń według priorytetów. Gdy użytkownik przewija UITableView, RunLoop przełącza się w tryb .tracking, w którym przetwarzane są tylko zdarzenia przewijania i odpowiednie timery/animacje. Wszystkie inne źródła (np. NSURLConnection) są wstrzymywane do czasu wyjścia z trybu przewijania.
Trzy główne tryby: .default (NSDefaultRunLoopMode) — główny tryb, w którym przetwarzane są wszystkie zdarzenia oprócz przewijania; .tracking (UITrackingRunLoopMode) — aktywowany podczas przewijania lub nawigacji gestami; .common (NSRunLoopCommonModes) — nie jest osobnym trybem, lecz zestawem aliasów obejmującym .default + .tracking. Dodanie źródła do .commonModes automatycznie dodaje je do wszystkich trybów w zestawie.
| Tryb | Stała Core Foundation | Stała Foundation | Kiedy aktywny |
|---|---|---|---|
| .default | kCFRunLoopDefaultMode | RunLoop.Mode.default | Normalny stan, bez przewijania |
| .tracking | UITrackingRunLoopMode | RunLoop.Mode.tracking | Przewijanie, gesture recognizers |
| .common | kCFRunLoopCommonModes | RunLoop.Mode.common | Tryb pseudo: default + tracking |
| .initialRun | kCFRunLoopInitialRunRunLoopMode | — | Pierwsze uruchomienie RunLoop |
Klasyczny problem: NSTimer dodany w trybie .default przestaje działać podczas przewijania, ponieważ RunLoop przełącza się w tryb .tracking i nie przetwarza timerów z .default. Rozwiązanie — dodaj timer do .commonModes: RunLoop.current.add(timer, forMode: .common). Spowoduje to, że timer będzie działał zarówno w .default, jak i .tracking. Alternatywą jest użycie DispatchQueue.main.async zamiast NSTimer, ponieważ GCD działa na poziomie wątków, a nie trybów RunLoop.
Wątki tła nie mają domyślnie RunLoop. Jeśli w wątku tła trzeba uruchomić NSTimer, przetwarzać NSInputStream/NSOutputStream lub reagować na performSelector:, należy ręcznie utworzyć i uruchomić RunLoop. Bez RunLoop timer i performSelector: nigdy nie zadziałają — wątek uruchomi kod i zakończy się, nie czekając na zdarzenia.
Aby utworzyć RunLoop w wątku tła, wystarczy wywołać RunLoop.current.run() na końcu pracy wątku. To wywołanie blokuje wątek na czas nieokreślony, przetwarzając zdarzenia. Do zatrzymania użyj CFRunLoopStop(CFRunLoopGetCurrent()). Ważne: RunLoop.current tworzy RunLoop leniwie przy pierwszym dostępie — jeśli nie wywołasz run(), nie będzie on przetwarzał zdarzeń. Wzorzec: konfiguracja źródeł -> dodanie do RunLoop -> wywołanie run().
import Foundation
// Wątek tła z własnym RunLoop
class BackgroundRunLoopManager {
private let thread: Thread
private var isRunning = false
init() {
thread = Thread { [weak self] in
// RunLoop jest tworzony automatycznie przy wywołaniu RunLoop.current
let runLoop = RunLoop.current
// Dodajemy port do utrzymania RunLoop aktywnego
runLoop.add(Port(), forMode: .default)
// Uruchamiamy przetwarzanie zdarzeń
var isFinished = false
while !isFinished {
// run(mode:before:) zwraca true, jeśli zdarzenie zostało przetworzone
isFinished = !runLoop.run(mode: .default, before: Date.distantFuture)
}
}
thread.name = "com.app.background-runloop"
}
func start() {
thread.start()
isRunning = true
}
func stop() {
// Zatrzymanie RunLoop w wątku tła
self.perform(
#selector(BackgroundRunLoopManager.stopRunLoop),
on: thread,
with: nil,
waitUntilDone: false
)
}
@objc
private func stopRunLoop() {
CFRunLoopStop(CFRunLoopGetCurrent())
isRunning = false
}
}
// Użycie: timer w RunLoop tła
let manager = BackgroundRunLoopManager()
manager.start()
// Wysłanie zadania do RunLoop tła przez performSelector
manager.perform(
#selector(BackgroundRunLoopManager.backgroundTask),
on: manager.thread,
with: nil,
waitUntilDone: false
)
BackgroundRunLoopManager tworzy wątek tła ze stałym RunLoop. Dodanie pustego Port() jest konieczne, aby RunLoop nie zakończył się natychmiast — bez źródeł RunLoop.run() zwraca false i wychodzi. performSelector:onThread: wysyła komunikat do RunLoop w tle — zostanie on przetworzony, gdy RunLoop wejdzie w fazę BeforeSources. Stop wywołuje CFRunLoopStop w wątku tła, kończąc cykl.
NSTimer tworzy zdarzenie czasowe, które RunLoop przetwarza w fazie BeforeTimers. Timery mogą być repeating (powtarzające się) i non-repeating (jednorazowe). NSTimer nie gwarantuje precyzji wyzwalania: jeśli RunLoop jest zablokowany przez długą operację, timer zadziała po odblokowaniu, a wszystkie pominięte wyzwolenia zostaną połączone w jedno (dla repeating timer — nie więcej niż jedno „nadrobione“ wyzwolenie).
CADisplayLink — specjalistyczny timer zsynchronizowany z częstotliwością odświeżania ekranu (60/120/144 Hz). Jest używany do animacji i aktualizacji wideo. CADisplayLink jest dodawany do RunLoop i wyzwalany przed każdą klatką renderowania (zanim Core Animation wyśle warstwę do renderowania). Jeśli klatka zostanie pominięta (display link nie zdążył wyzwolić w ciągu 16 ms), następne wywołanie nastąpi w następnym cyklu VSync.
import UIKit
class AnimationController {
private var displayLink: CADisplayLink?
private var displayLinkTimer: Timer?
private var startTime: CFTimeInterval = 0
// CADisplayLink — animacja z vsync
func startDisplayLinkAnimation() {
displayLink = CADisplayLink(target: self,
selector: #selector(step))
// Dodanie w trybie .common — działa również podczas przewijania
displayLink?.add(to: .current, forMode: .common)
startTime = CACurrentMediaTime()
}
@objc
private func step(displayLink: CADisplayLink) {
let elapsed = CACurrentMediaTime() - startTime
// Wywoływany każdą klatkę (60 FPS → co 16.6 ms)
print("Frame at \(elapsed) seconds")
if elapsed > 5.0 {
displayLink.invalidate() // zatrzymanie po 5 sekundach
}
}
// NSTimer — zadanie okresowe
func startTimerInCommonMode() {
displayLinkTimer?.invalidate()
displayLinkTimer = Timer.scheduledTimer(
withTimeInterval: 1.0,
repeats: true
) { [weak self] timer in
print("Timer tick")
}
// KLUCZ: dodajemy w .common, inaczej timer zamknie przy przewijaniu
RunLoop.current.add(displayLinkTimer!, forMode: .common)
}
func stop() {
displayLink?.invalidate()
displayLinkTimer?.invalidate()
}
}
W AnimationController CADisplayLink jest dodany w trybie .common, co gwarantuje wywołanie step na każdej klatce niezależnie od przewijania. displayLink.add(to: .current, forMode: .common) — standardowy wzorzec dla animacji, które nie powinny być przerywane podczas przewijania. NSTimer również został dodany w trybie .common, aby tykał podczas przewijania. Bez tego timer działałby tylko w trybie .default.
CFRunLoopObserver — mechanizm do śledzenia faz RunLoop. Za pomocą Observer można otrzymywać powiadomienia o wejściu w tryb, rozpoczęciu przetwarzania timerów, rozpoczęciu przetwarzania źródeł, wybudzeniu po śnie, wyjściu z trybu. Observer-y są używane przez frameworki do własnych potrzeb: Core Animation używa ich do renderowania warstw przed zaśnięciem RunLoop, UIKit — do aktualizacji layoutu po przetworzeniu zdarzeń.
Deweloper może również dodawać Observer-y do własnych celów. Na przykład: pomiar czasu przetwarzania zdarzeń (profilowanie), wykonywanie odroczonych operacji przed zaśnięciem RunLoop (gdy UI jest już zaktualizowane, a użytkownik nie wchodzi w interakcję), automatyczne zapisywanie danych podczas długiego bezczynności. Observer rejestruje się przez CFRunLoopAddObserver z określeniem trybu i maski bitowej śledzonych aktywności.
Najbardziej przydatne punkty dla Observer: .afterWaiting — wykonywany po wybudzeniu RunLoop i może zawierać kod, który powinien uruchomić się po przetworzeniu zdarzenia; .beforeTimers — przed przetwarzaniem timerów, pozwala zmierzyć czas od poprzedniego przetworzenia; .exit — wyzwalany przy zatrzymaniu RunLoop, przydatny do czyszczenia zasobów wątku tła.
CFRunLoopStop — funkcja, która wymusza zakończenie bieżącej iteracji RunLoop. Po wywołaniu CFRunLoopStop(CFRunLoopGetCurrent()) RunLoop kończy przetwarzanie bieżącego zdarzenia i wychodzi z run(), zwracając false. Jest to standardowy sposób zatrzymania RunLoop w wątku tła. Na Main Thread CFRunLoopStop nie jest zalecane — główny RunLoop powinien działać przez cały czas życia aplikacji. Dla wątków tła po CFRunLoopStop wątek może zakończyć się lub kontynuować wykonywanie następnego kodu po run().
Często zadawane pytania
RunLoop — pętla zdarzeń w iOS zaimplementowana przez CFRunLoop (Core Foundation) i NSRunLoop (Foundation). Oczekuje na zdarzenia (dotknięcia, timery, źródła wejścia) i przekazuje je do handlerów w wątku. Każdy wątek może mieć jeden RunLoop, ale automatycznie jest tworzony tylko dla Main Thread. RunLoop zarządza trybami (.default, .tracking, .common), izolując przetwarzanie według priorytetów.
NSTimer domyślnie jest dodawany w trybie .default RunLoop. Gdy użytkownik przewija, RunLoop przełącza się w tryb .tracking i nie przetwarza timerów z .default. Rozwiązanie: dodaj timer w trybie .common przez RunLoop.current.add(timer, forMode: .common). .common łączy .default i .tracking, więc timer działa w obu trybach.
Tylko jeśli wątek tła używa timerów (NSTimer), performSelector:onThread:, NSInputStream/NSOutputStream lub zdarzeń Source. Jeśli wątek wykonuje synchroniczne zadanie (pobieranie pliku, obliczenia) i kończy się — RunLoop nie jest potrzebny. Aby uruchomić, wywołaj RunLoop.current.run() po skonfigurowaniu źródeł. Do zatrzymania — CFRunLoopStop(CFRunLoopGetCurrent()).
RunLoop działa na poziomie wątku i przetwarza zdarzenia sekwencyjnie, z obsługą trybów (modes). DispatchQueue — abstrakcja puli wątków, zadania są wykonywane na dowolnym wolnym wątku. GCD nie obsługuje trybów i żyje niezależnie od RunLoop. DispatchQueue.main używa głównego RunLoop do wykonywania bloków — to jedyny punkt przecięcia. Dla zadań w tle preferowany jest GCD.
CADisplayLink — to timer zsynchronizowany z VSync (częstotliwością odświeżania ekranu). Jest dodawany do RunLoop i wyzwalany przed każdą klatką renderowania w fazie BeforeTimers. CADisplayLink działa tylko na Main Thread, ponieważ renderowanie ekranu odbywa się tam. Dla ciągłych animacji podczas przewijania dodawaj go w trybie .common: displayLink.add(to: .current, forMode: .common).
Podsumowanie
Opracujemy aplikację mobilną pod klucz
IT Sectr tworzy aplikacje na iOS i Androida dla startupów i firm od 2017 roku. Doradzimy Ci i zaproponujemy najlepsze rozwiązanie.
Przeczytaj również