공부하면서 정리한 내용이기에 잘못된 내용이 있을 수 있습니다. 댓글 피드백 환영합니다!
RunLoop에 대해 생소하시다면 개발자 소들이님의 'iOS) 런 루프(RunLoop) 이해하기' 글을 먼저 읽어보시길 강력 추천드립니다.
Apple에서 제공하는 Combine
프레임워크를 사용하다보면 다음과 같은 상황을 마주할 때가 있다.
func receiveOnDispatchQueue() {
somePublisher
.receive(on: DispatchQueue.main)
.sink { _ in
// ...
}
.store(in: &cancellables)
}
func receiveOnRunLoop() {
somePublisher
.receive(on: RunLoop.main)
.sink { _ in
// ...
}
.store(in: &cancellables)
}
sink 내부에는 보통 메인쓰레드에서 동작해야 하는 작업들, 이를테면 UI 업데이트와 같은 로직들을 넣게 되는데, 이 때 중요한건 Scheduler를 지정하는 부분이다.
Scheduler로 DispatchQueue.main
을 썼을 때와 RunLoop.main
을 썼을 때, 차이가 존재할까? 그냥 둘 중 아무거나 사용해도 상관없는걸까?
아래의 뷰 및 코드로 두 Scheduler에 차이가 존재하는지 테스트를 진행해보았다.
countStore
에서 화면에 보여줄 숫자에 대한 데이터를 가지고 있도록 함countStore
에 1 더한 값을 보냄countStore
에서 값이 방출되면 해당 값을 countLabel
에 적용함//
// ViewController.swift
// DispatchQueueRunLoop
//
// Created by JINHONG AN on 2022/11/01.
//
import UIKit
import Combine
final class ViewController: UIViewController {
@IBOutlet private weak var countLabel: UILabel!
@IBOutlet private weak var button: UIButton!
private var countStore = CurrentValueSubject<Int, Never>(0)
private var cancellables: Set<AnyCancellable> = []
override func viewDidLoad() {
super.viewDidLoad()
countStore
.receive(on: DispatchQueue.main)
.sink { [weak self] in
self?.countLabel.text = $0.description
}
.store(in: &cancellables)
button.addTarget(self, action: #selector(didTapButton), for: .touchUpInside)
}
@objc private func didTapButton() {
Timer.publish(every: 1, on: .main, in: .common)
.autoconnect()
.sink { [weak self] _ in
guard let self = self else { return }
let nextCount = self.countSubject.value + 1
self.countSubject.send(nextCount)
}
.store(in: &cancellables)
}
}
(위의 코드와 Scheduler부분만 다름)
//
// ViewController.swift
// DispatchQueueRunLoop
//
// Created by JINHONG AN on 2022/11/01.
//
import UIKit
import Combine
final class ViewController: UIViewController {
@IBOutlet private weak var countLabel: UILabel!
@IBOutlet private weak var button: UIButton!
private var countStore = CurrentValueSubject<Int, Never>(0)
private var cancellables: Set<AnyCancellable> = []
override func viewDidLoad() {
super.viewDidLoad()
countStore
.receive(on: RunLoop.main) // 위의 코드와 이 부분만 다름
.sink { [weak self] in
self?.countLabel.text = $0.description
}
.store(in: &cancellables)
button.addTarget(self, action: #selector(didTapButton), for: .touchUpInside)
}
@objc private func didTapButton() {
Timer.publish(every: 1, on: .main, in: .common)
.autoconnect()
.sink { [weak self] _ in
guard let self = self else { return }
let nextCount = self.countSubject.value + 1
self.countSubject.send(nextCount)
}
.store(in: &cancellables)
}
}
DispatchQueue.main | RunLoop.main |
---|---|
둘의 차이는 없어보인다..! 그리고 둘의 차이는 실제로도 거의 없다.
거의 없다고 한 것에 주목해야 한다. 둘은 비슷하지만 분명히 차이가 존재한다.
어떤 차이가 존재하는 것일까???
이번에는 스크롤 뷰를 넣고 테스트를 해보았다.
결과는 어떨까? 이전과 같을까?
코드는 이전과 동일하게 구성하였다.
버튼을 누르고 카운트가 올라갈 때 스크롤 제스쳐(위아래 드래그)를 줘보았다.
DispatchQueue.main | RunLoop.main |
---|---|
차이가 보이는가?!?
RunLoop.main
으로 Scheduler를 지정해준 오른쪽은 드래그를 하는 동안 숫자 업데이트가 이루어지지 않았다.
그리고 탭을 놓는 순간 숫자가 한번에 확 바뀌었다. 왜 이렇게 된걸까?
우선은 RunLoop
에 대한 이해가 필요하다.
아마도 위와 같은 그림을 많이 보았을 것이다.(출처: Threading Programming Guide - Apple)
간단하게 한줄로 요약하자면 RunLoop
는 이벤트 처리 루프로 키보드, 마우스같은 input source 및 Timer 등의 이벤트들을 처리한다.
이 RunLoop에는 사실 5가지 모드가 있다. 애플 공식문서에는 아래와 같이 소개하고 있다.
common
- 하나 이상의 run loop 모드를 포함하는 가짜-모드(슈도 모드)default
- 연결 object이외의 input source를 처리하는 모드event tracking
- 마우스 드래깅 loop같이 이벤트를 모달로 추적할 때 설정되는 모드modalPanel
- 저장 또는 열기 패널과 같은 모달 패널에서 입력을 기다릴 때 설정되는 모드tracking
- 컨트롤을 추적하는 동안 설정되는 모드위의 모드들 중
event tracking
과modalPanel
은 macOS에만 존재하는 모드이다. 또한 macOS에는tracking
모드가 없다.
즉, iOS에는common
,default
,tracking
의 세가지 모드만 존재한다.
RunLoop.Mode
테스트그러면 어떠한 경우에 default
와 tracking
모드 설정이 되는 걸까?
(common
은 여러 모드를 포함하는 슈도-개념이므로 위의 두 모드에 대해서만 테스트 해보았다.)
스크롤 뷰 내에 어떤 RunLoop 상태인지를 출력할 레이블 하나만 두었다.
//
// ViewController.swift
// DispatchQueueRunLoop
//
// Created by JINHONG AN on 2022/11/01.
//
import UIKit
import Combine
final class ViewController: UIViewController {
@IBOutlet private weak var runloopModeLabel: UILabel!
private var cancellables: Set<AnyCancellable> = []
override func viewDidLoad() {
super.viewDidLoad()
Timer.publish(every: 1, on: .main, in: .common)
.autoconnect()
.sink { [weak self] _ in
self?.runloopModeLabel.text = RunLoop.current.currentMode?.rawValue
}
.store(in: &cancellables)
}
}
1초마다 레이블에 현재 메인쓰레드의 메인런루프가 어떤 모드인지 출력되도록 만들었다.
스크롤이 안이루어지고 있을 때 - kCFRunLoopDefaultMode
RunLoop.Mode
의 default
와 동일스크롤이 이루어지고 있을 때 - UITrackingRunLoopMode
RunLoop.Mode
의 tracking
과 동일RunLoop.main
과 RunLoop.Mode
'RunLoop.main
Scheduler와 RunLoop.Mode
가 무슨 상관이냐!' 하고 생각할 수 있다. 하지만 정말 큰 상관이 있다!!
RunLoop.main
Scheduler는 메인 런루프가 default
모드일 때만 sink 클로져를 수행하기 때문이다.
즉,
RunLoop.main
으로 Scheduler를 설정하면 > 사용자가 화면에 이벤트를 발생시키고 있을 때(tracking
모드 일 때)에는 sink 클로저가 수행되지 않다가 > 사용자가 이벤트 발생을 중단하면(default
모드로 되돌아 왔을 때) sink 클로저가 수행된다.
DispatchQueue.main
은?DispatchQueue.main
Scheduler는 Runloop
모드와는 상관없이 항상 동작한다.
DispatchQueue.main
Scheduler는 내부적으로 작업을 GCD로 처리하기 때문이다.
Schedulers+RunLoop.swift | Schedulers+DispatchQueue.swift |
---|---|
RunLoop.main
Scheduler는 내부적으로 RunLoop.perform
메소드를 사용하여 작업을 처리한다. RunLoop.perform
은 RunLoop.Mode
가 default
일 때만 동작하기 때문에 위와 같은 결과들이 나왔던 것이다.(stackoverflow에 적혀 있는 내용이며, 이 부분에 대해서는 공식자료를 발견하지 못하였습니다. 혹시 아시는 분이 계시다면 댓글 부탁드리겠습니다.)DispatchQueue.main
Scheduler는 GCD로 작업을 처리시키는 것을 볼 수 있다. 만약
RunLoop.main
스케쥴러 내부 구현이RunLoop.perform()
이 아닌RunLoop.perform(inModes: block:)
으로 구현되어있었다면....?
자 그래서 우리는 중요한 사실 한 가지를 학습했다.
Combine Scheduler인 RunLoop.main
과 DispatchQueue.main
은 구분해서 사용해야 한다는 것을
언제
RunLoop.main
을 써야 할지는 고민을 좀 더 해봐야 할 것 같다.
'사용자 터치가 일어나는 중에는 화면 업데이트가 이루어지지 않아야 함' ⬅️ 이런 경우가 언제 있을까?
위의 코드 중에서 뭔가 낯설어보이는게 있지 않은가??
Timer.publish(every: 1, on: .main, in: .common) // 두번째와 세번째 인자 주목
그렇다. 두번째 인자는 RunLoop
이며 세번째 인자는 RunLoop.Mode
이다.
어떤 RunLoop
에서 어떤 Mode
일 때 타이머를 동작(run)시킬지 결정하는 부분이다.
당연하겠지만 세번째 인자를 .default
로 주게 되면 사용자 터치이벤트가 진행중일 때에는 타이머가 동작하지 않는다. (publishing이 이루어지지 않음)
스크롤 뷰 내에 레이블과 버튼이 있는 상태
//
// ViewController.swift
// DispatchQueueRunLoop
//
// Created by JINHONG AN on 2022/11/01.
//
import UIKit
import Combine
final class ViewController: UIViewController {
@IBOutlet private weak var countLabel: UILabel!
@IBOutlet private weak var button: UIButton!
private var countSubject = CurrentValueSubject<Int, Never>(0)
private var cancellables: Set<AnyCancellable> = []
override func viewDidLoad() {
super.viewDidLoad()
countSubject
.receive(on: DispatchQueue.main) // 숫자가 설정되는 부분은 사용자 터치와는 상관 없도록 함
.sink { [weak self] in
self?.countLabel.text = $0.description
print("방출된 값 받았으며 값 설정 완료 \($0)")
}
.store(in: &cancellables)
button.addTarget(self, action: #selector(didTapButton), for: .touchUpInside)
}
@objc private func didTapButton() {
Timer.publish(every: 1, on: .main, in: .default) // 이 부분에 주목하자!
.autoconnect()
.sink { [weak self] _ in
guard let self = self else { return }
let nextCount = self.countSubject.value + 1
self.countSubject.send(nextCount)
print("값 전송 \(nextCount)")
}
.store(in: &cancellables)
}
}
RunLoop.main
설정에 의한 작업들은 누적되는가? 스킵되는가?사용자 터치가 발생하여 값의 설정이 미뤄지고 있다면
스크롤 뷰 내에 레이블과 버튼이 있는 상태
//
// ViewController.swift
// DispatchQueueRunLoop
//
// Created by JINHONG AN on 2022/11/01.
//
import UIKit
import Combine
final class ViewController: UIViewController {
@IBOutlet private weak var countLabel: UILabel!
@IBOutlet private weak var button: UIButton!
private var countSubject = CurrentValueSubject<Int, Never>(0)
private var cancellables: Set<AnyCancellable> = []
override func viewDidLoad() {
super.viewDidLoad()
countSubject
.receive(on: RunLoop.main) // 스케쥴러는 RunLoop.main으로 설정
.sink { [weak self] in
self?.countLabel.text = $0.description
// 유저 이벤트에 의해 멈춰있다 다시 실행될 때 맨 마지막 클로저만 실행되는 것인지
// 아니면 실행 되어야 할 클로저 블록들이 큐처럼 (이벤트 큐에?)쌓여있다가 한번에 순차적으로 쭈루룩 동작하는 것인지 체크
print("방출된 값 받았으며 값 설정 완료 \($0)")
}
.store(in: &cancellables)
button.addTarget(self, action: #selector(didTapButton), for: .touchUpInside)
}
@objc private func didTapButton() {
Timer.publish(every: 1, on: .main, in: .common) // Timer는 언제나 동작하도록 common 설정
.autoconnect()
.sink { [weak self] _ in
guard let self = self else { return }
let nextCount = self.countSubject.value + 1
self.countSubject.send(nextCount)
print("값 전송 \(nextCount)")
}
.store(in: &cancellables)
}
}
Timer에 의한 값 설정은 문제 없이 항상 이루어지고 있으며 터치가 끝나는 순간 밀려있던 UI작업들이 모두 한번에 실행되는 것을 확인할 수 있다.
추가 확인이 필요한 부분
1. 밀리는 클로저 작업들은 어디에 누적되는 것인지
2. UI를 변경하는 코드가 한번에 여러개 실행되는데 실질적인 렌더링은 몇번 이루어지는지