RunLoop — цикъл на обработка на събития в iOS, реализиран от обектите CFRunLoop (Core Foundation) и NSRunLoop (Foundation). Това е механизъм, който очаква събития (докосвания, таймери, входни източници, известия) и ги насочва към съответните обработващи програми в нишката. Всяка нишка в iOS има максимум един RunLoop, но той се създава автоматично само за Main Thread. Според документацията на Apple CFRunLoop, RunLoop е критичен за работата на таймери, анимации и мониторинг на източници във фонов режим.
Основни неща
RunLoop — инфраструктурен обект на Core Foundation, който организира обработката на събития в нишката. По същество това е безкраен цикъл (while true), който очаква пристигането на събития (sources) и ги предава на обработващите програми. Когато няма събития, RunLoop поставя нишката в режим на сън (sleep), пестейки енергия на батерията. При пристигане на събитие нишката се събужда, обработва го и отново заспива. RunLoop съществува само в iOS/macOS (XNU + Core Foundation) — в Android неговата роля се изпълнява от Looper.
Всяка нишка има най-много един RunLoop, който се създава мързеливо (lazy) при първия достъп. За Main Thread RunLoop се създава автоматично при стартиране на приложението. За фонов нишки RunLoop не се създава, докато не бъде извикан CFRunLoopGetCurrent() или RunLoop.current. Основният RunLoop на приложението отговаря за обработката на докосвания, изобразяване на екрана, изпълнение на блокове DispatchQueue.main и обслужване на слоеве Core Animation.
RunLoop не е нишка — това е механизъм вътре в нишката. Нишка може да съществува без RunLoop (ако изпълнява синхронна задача и приключва), но RunLoop не може да съществува без нишка. Когато нишка с активен RunLoop няма събития, тя не блокира CPU, а е в състояние на изчакване (waiting) — това е ключовата разлика от busy-wait, който използва 100% от CPU.
RunLoop обработва два типа източници на събития: Input Sources (входни източници) и Timer Sources (таймери). Input Sources доставят асинхронни събития: докосвания, движения на мишката, данни от сокет, съобщения от други нишки (performSelector:onThread:). Timer Sources доставят синхронни събития по график: NSTimer, CADisplayLink. Съществуват също Observers — входни точки за наблюдение на състоянието на RunLoop.
Цикълът на RunLoop се състои от последователни фази: влизане в режим (kCFRunLoopEntry), обработка на таймери (kCFRunLoopBeforeTimers), обработка на входни източници (kCFRunLoopBeforeSources), обработка на източници (kCFRunLoopAfterWaiting), изчакване (sleep), излизане от режим (kCFRunLoopExit). Ако в текущата итерация не е обработено нито едно събитие, RunLoop поставя нишката в сън за неопределено време до събуждане от ново събитие.
import Foundation
// Демонстрация на фазите на RunLoop чрез 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 е активиран")
case .beforeTimers:
print("BeforeTimers — обработка на таймери")
case .beforeSources:
print("BeforeSources — обработка на източници")
case .afterWaiting:
print("AfterWaiting — събуждане след сън")
case .exit:
print("Exit — RunLoop е завършен")
default:
break
}
}
CFRunLoopAddObserver(
CFRunLoopGetCurrent(),
observer,
.commonModes
)
}
// Пример: RunLoop обработва таймер на основната нишка
func timerOnMainRunLoop() {
Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { timer in
print("Tick: \(Date())")
}
// RunLoop.current.run() на Main Thread се извиква от UIApplicationMain
// автоматично — не е необходимо ръчно стартиране
RunLoop.current.run() // Това извикване няма да се върне на Main Thread
}
Примерът observeRunLoopActivities регистрира Observer в основния RunLoop, който записва всяка фаза от цикъла. Това е полезно за отстраняване на грешки: ако видите дълъг интервал между .beforeTimers и .afterWaiting, това означава, че RunLoop е блокиран от операция в Main Thread. timerOnMainRunLoop показва как NSTimer автоматично работи в основния RunLoop — при създаване на Timer.scheduledTimer таймерът се добавя към текущия RunLoop по подразбиране (.default mode).
Android Looper — аналог на RunLoop. Looper.prepare() създава опашка от съобщения (MessageQueue) в нишката, Looper.loop() стартира безкраен цикъл на обработка. Handler изпраща съобщения и Runnable в тази опашка. Основната разлика: RunLoop поддържа режими (modes), а Android Looper не. Looper обработва всички съобщения без филтриране по режим, което го прави по-прост, но по-малко гъвкав в сценарии с приоритети (напр. скролирането в iOS се обработва в .tracking режим отделно от други събития).
RunLoop Mode — набор от източници, таймери и наблюдатели, които са активни в даден момент. Режимите позволяват изолиране на обработката на събития по приоритети. Когато потребителят скролира UITableView, RunLoop превключва в .tracking режим, в който се обработват само събития за скролиране и съответните таймери/анимации. Всички останали източници (напр. NSURLConnection) се спират до излизане от режима на скролиране.
Три основни режима: .default (NSDefaultRunLoopMode) — основният режим, в който се обработват всички събития освен скролиране; .tracking (UITrackingRunLoopMode) — активира се при скролиране или жестова навигация; .common (NSRunLoopCommonModes) — не е отделен режим, а набор от псевдоними, който включва .default + .tracking. Добавянето на източник към .commonModes автоматично го добавя към всички режими от набора.
| Режим | Core Foundation константа | Foundation константа | Кога е активен |
|---|---|---|---|
| .default | kCFRunLoopDefaultMode | RunLoop.Mode.default | Нормално състояние, без скролиране |
| .tracking | UITrackingRunLoopMode | RunLoop.Mode.tracking | Скролиране, gesture recognizers |
| .common | kCFRunLoopCommonModes | RunLoop.Mode.common | Псевдо-режим: default + tracking |
| .initialRun | kCFRunLoopInitialRunRunLoopMode | — | Първо стартиране на RunLoop |
Класически проблем: NSTimer, добавен в .default режим, спира да работи по време на скролиране, защото RunLoop превключва в .tracking режим и не обработва таймери от .default. Решение — добавете таймера към .commonModes: RunLoop.current.add(timer, forMode: .common). Това ще накара таймера да работи както в .default, така и в .tracking. Алтернатива е използването на DispatchQueue.main.async вместо NSTimer, тъй като GCD работи на ниво нишки, а не на ниво RunLoop режими.
Фонов нишки нямат RunLoop по подразбиране. Ако във фонова нишка трябва да стартирате NSTimer, да обработвате NSInputStream/NSOutputStream или да реагирате на performSelector:, трябва ръчно да създадете и стартирате RunLoop. Без RunLoop таймерът и performSelector: никога няма да работят — нишката ще изпълни кода и ще приключи, без да чака събития.
За да създадете RunLoop във фонова нишка, достатъчно е да извикате RunLoop.current.run() в края на работата на нишката. Това извикване блокира нишката за неопределено време, обработвайки събития. За спиране използвайте CFRunLoopStop(CFRunLoopGetCurrent()). Важно: RunLoop.current създава RunLoop мързеливо при първия достъп — ако не извикате run(), той няма да обработва събития. Модел: конфигуриране на източници -> добавяне към RunLoop -> извикване на run().
import Foundation
// Фонова нишка със собствен RunLoop
class BackgroundRunLoopManager {
private let thread: Thread
private var isRunning = false
init() {
thread = Thread { [weak self] in
// RunLoop се създава автоматично при извикване на RunLoop.current
let runLoop = RunLoop.current
// Добавяме порт за поддържане на RunLoop активен
runLoop.add(Port(), forMode: .default)
// Стартираме обработка на събития
var isFinished = false
while !isFinished {
// run(mode:before:) връща true, ако събитие е обработено
isFinished = !runLoop.run(mode: .default, before: Date.distantFuture)
}
}
thread.name = "com.app.background-runloop"
}
func start() {
thread.start()
isRunning = true
}
func stop() {
// Спиране на RunLoop във фонова нишка
self.perform(
#selector(BackgroundRunLoopManager.stopRunLoop),
on: thread,
with: nil,
waitUntilDone: false
)
}
@objc
private func stopRunLoop() {
CFRunLoopStop(CFRunLoopGetCurrent())
isRunning = false
}
}
// Използване: таймер на фонов RunLoop
let manager = BackgroundRunLoopManager()
manager.start()
// Изпращане на задача към фонов RunLoop чрез performSelector
manager.perform(
#selector(BackgroundRunLoopManager.backgroundTask),
on: manager.thread,
with: nil,
waitUntilDone: false
)
BackgroundRunLoopManager създава фонова нишка с постоянен RunLoop. Добавянето на празен Port() е необходимо, за да не приключи RunLoop веднага — без източници RunLoop.run() връща false и излиза. performSelector:onThread: изпраща съобщение до фонов RunLoop — то ще бъде обработено, когато RunLoop влезе във фаза BeforeSources. Stop извиква CFRunLoopStop във фоновата нишка, прекратявайки цикъла.
NSTimer създава таймер събитие, което RunLoop обработва във фаза BeforeTimers. Таймерите могат да бъдат repeating (повтарящи се) и non-repeating (еднократни). NSTimer не гарантира точност: ако RunLoop е блокиран от дълга операция, таймерът ще се активира след деблокиране и всички пропуснати активации ще се слеят в една (за повтарящ се таймер — най-много една „наваксваща“ активация).
CADisplayLink — специализиран таймер, синхронизиран с честотата на опресняване на екрана (60/120/144 Hz). Използва се за анимации и актуализации на видео. CADisplayLink се добавя към RunLoop и се активира преди всеки кадър за изобразяване (преди Core Animation да изпрати слоя за изобразяване). Ако кадър бъде пропуснат (display link не е успял да се активира за 16 ms), следващото извикване се случва в следващия цикъл VSync.
import UIKit
class AnimationController {
private var displayLink: CADisplayLink?
private var displayLinkTimer: Timer?
private var startTime: CFTimeInterval = 0
// CADisplayLink — анимация с vsync
func startDisplayLinkAnimation() {
displayLink = CADisplayLink(target: self,
selector: #selector(step))
// Добавяне в .common режим — работи и при скролиране
displayLink?.add(to: .current, forMode: .common)
startTime = CACurrentMediaTime()
}
@objc
private func step(displayLink: CADisplayLink) {
let elapsed = CACurrentMediaTime() - startTime
// Извиква се на всеки кадър (60 FPS → на всеки 16.6 ms)
print("Frame at \(elapsed) seconds")
if elapsed > 5.0 {
displayLink.invalidate() // спиране след 5 секунди
}
}
// NSTimer — периодична задача
func startTimerInCommonMode() {
displayLinkTimer?.invalidate()
displayLinkTimer = Timer.scheduledTimer(
withTimeInterval: 1.0,
repeats: true
) { [weak self] timer in
print("Timer tick")
}
// КЛЮЧ: добавяме в .common, иначе таймерът замръзва при скролиране
RunLoop.current.add(displayLinkTimer!, forMode: .common)
}
func stop() {
displayLink?.invalidate()
displayLinkTimer?.invalidate()
}
}
В AnimationController CADisplayLink е добавен в .common режим, което гарантира извикване на step на всеки кадър независимо от скролирането. displayLink.add(to: .current, forMode: .common) — стандартният модел за анимации, които не трябва да се прекъсват по време на скролиране. NSTimer също е добавен в .common режим, за да тиктака по време на скролиране. Без това таймерът би работил само в .default режим.
CFRunLoopObserver — механизъм за проследяване на фазите на RunLoop. Чрез Observer можете да получавате известия за влизане в режим, начало на обработка на таймери, начало на обработка на източници, събуждане от сън, излизане от режим. Observers се използват от рамки за техните нужди: Core Animation ги използва за изобразяване на слоеве преди сън на RunLoop, UIKit — за актуализиране на layout след обработка на събития.
Разработчикът също може да добавя Observers за свои цели. Например: измерване на времето за обработка на събития (профилиране), изпълнение на отложени операции преди сън на RunLoop (когато UI вече е актуализиран и потребителят не взаимодейства), автоматично запазване на данни при продължително бездействие. Observer се регистрира чрез CFRunLoopAddObserver с посочване на режим и битова маска на проследяваните дейности.
Най-полезните точки за Observer: .afterWaiting — изпълнява се след събуждане на RunLoop и може да съдържа код, който трябва да се стартира след обработка на събитие; .beforeTimers — преди обработка на таймери, позволява измерване на времето от предишната обработка; .exit — активира се при спиране на RunLoop, полезно за почистване на ресурси на фонова нишка.
CFRunLoopStop — функция, която принудително прекратява текущата итерация на RunLoop. При извикване на CFRunLoopStop(CFRunLoopGetCurrent()) RunLoop прекратява обработката на текущото събитие и излиза от run(), връщайки false. Това е стандартният начин за спиране на RunLoop във фонова нишка. На Main Thread CFRunLoopStop не се препоръчва — основният RunLoop трябва да работи през целия живот на приложението. За фонов нишки след CFRunLoopStop нишката може да приключи или да продължи изпълнението на следващия код след run().
Често задавани въпроси
RunLoop — цикъл на събития в iOS, реализиран от CFRunLoop (Core Foundation) и NSRunLoop (Foundation). Той очаква събития (докосвания, таймери, входни източници) и ги насочва към обработващи програми в нишката. Всяка нишка може да има един RunLoop, но автоматично се създава само за Main Thread. RunLoop управлява режими (.default, .tracking, .common), изолирайки обработката по приоритети.
NSTimer по подразбиране се добавя в .default режим на RunLoop. Когато потребителят скролира, RunLoop превключва в .tracking режим и не обработва таймери от .default. Решение: добавете таймера в .common режим чрез RunLoop.current.add(timer, forMode: .common). .common обединява .default и .tracking, така че таймерът работи и в двата режима.
Само ако фоновата нишка използва таймери (NSTimer), performSelector:onThread:, NSInputStream/NSOutputStream или Source събития. Ако нишката изпълнява синхронна задача (изтегляне на файл, изчисления) и приключва — RunLoop не е необходим. За стартиране извикайте RunLoop.current.run() след конфигуриране на източниците. За спиране — CFRunLoopStop(CFRunLoopGetCurrent()).
RunLoop работи на ниво нишка и обработва събития последователно, с поддръжка на режими (modes). DispatchQueue — абстракция на пул от нишки, задачите се изпълняват на всяка свободна нишка. GCD не поддържа режими и съществува независимо от RunLoop. DispatchQueue.main използва основния RunLoop за изпълнение на блокове — това е единствената пресечна точка. За фонов задачи GCD е за предпочитане.
CADisplayLink — таймер, синхронизиран с VSync (честота на опресняване на екрана). Той се добавя към RunLoop и се активира преди всеки кадър за изобразяване във фаза BeforeTimers. CADisplayLink работи само на Main Thread, тъй като изобразяването на екрана се извършва там. За непрекъснати анимации по време на скролиране го добавете в .common режим: displayLink.add(to: .current, forMode: .common).
Обобщение
Ще разработим мобилно приложение под ключ
IT Sectr създава iOS и Android приложения за стартъпи и бизнеси от 2017 г. Ще ви консултираме и ще предложим най-доброто решение.
Прочетете също