본문 바로가기

학습 노트/iOS (2021)

169 ~ 170. Timer & Runloop and Concurrency Programming

Timer & Runloop

Timer는 지정된 주기마다 반복되는 코드를 실행할 때 사용할 수 있다.
Timer가 제공하는 API는 단순하지만 Runloop에 대한 이해가 없다면 원하는 결과를 얻기 어렵다.

Runloop

다양한 이벤트를 처리하기 위해 사용하는 Event Processing Loop이다.
앱이 시작되면 Main Thread에서 동작하는 Runloop가 자동으로 생성된다.
Background Thread에서 Runloop가 필요한 경우 직접 구성해야 한다.

Runloop는 Thread에서 발생하는 Event Source를 감시한다.
iOS는 Event Source로부터 새로운 Event가 도착하면 Thread를 깨워 Runloop로 이벤트를 전달한다.
이후 Runloop는 등록된 Handler를 사용해 이벤트를 처리한다.
처리할 이벤트가 없을 경우 Thread를 대기상태로 전환하는데, 이것이 Runloop 사용의 주된 목적이다.
처리를 위해 지속적으로 pulling 할 필요가 없기 때문에 모바일 환경에서의 앱 성능의 향상을 기대할 수 있다.

Timer는 이러한 Runloop와 함께 동작한다.
Timer를 생성하고 반복적으로 코드를 실행하려면 반드시 Runloop에 추가해야 한다.
이렇게 추가된 Timer는 Runloop의 소유가 되고, 명시적으로 Timer를 제거하기 전까지 지속해서 이벤트를 처리한다.

Timer는 오차 없이 동작하지 않는다.
Runloop나 시스템의 상태에 따라 약간의 오차가 발생할 가능성이 있다.
하지만 인식할 수 없을 정도로 미비한 수준으로, 작은 오차를 허용할 수 있다면 크게 문제 되지 않는다.
Timer가 제공하는 API를 통해 허용 오차를 설정하면 최적화에 도움이 된다.
System은 허용된 오차와 Resource 상태를 감안해 실행 시점을 조절한다.
이러한 최적화를 통해 배터리를 절약하고, 응답성을 향상할 수 있다.

Timer

//
//  TimerViewController.swift
//  Concurrency Practice
//
//  Created by Martin.Q on 2021/12/22.
//

import UIKit

class TimerViewController: UIViewController {
	
	@IBOutlet weak var timeLabel: UILabel!
	
	lazy var formatter: DateFormatter = {
		let f = DateFormatter()
		f.dateFormat = "hh:mm:ss"
		return f
	}()
	
	@IBAction func unwindToTimerHome(_ unwindSegue: UIStoryboardSegue) {
	}
	
	func updateTimer(_ timer: Timer) {
		print(#function, Date(), timer)
		timeLabel.text = formatter.string(from: Date())
	}
	
	func resetTimer() {
		timeLabel.text = "00:00:00"
	}
	
	var timer: Timer?
	
	
	@IBAction func start(_ sender: Any) {
	}
	@IBAction func stop(_ sender: Any) {
	}
	
	

    override func viewDidLoad() {
        super.viewDidLoad()

        resetTimer()
    }
	
	override func viewWillAppear(_ animated: Bool) {
		super.viewWillAppear(animated)
	}
	override func viewWillDisappear(_ animated: Bool) {
		super.viewWillDisappear(animated)
	}
}

사용할 Scene과 연결된 코드는 위와 같다.
Label은 timeLabel에, 각각의 버튼은 start와 stop 메서드에 연결돼있다.
Timer를 사용해 Label에 현재 시간을 출력한다.

타이머를 생성하는 방법은 두 가지이다.

Timer의 Type 메서드 사용하기

주기적으로 실행할 코드는 세 가지 방식으로 전달할 수 있다.
각각 Block, Selector, NSInvocation 방식으로 전달할 수 있고, 그중 NSInvocation은 사용 빈도가 낮다.
이번엔 Block을 사용해 생성한다.

@IBAction func start(_ sender: Any) {
	timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true, block: { (timer) in
		guard timer.isValid else { return }
	})
}

첫 번째 파라미터는 반복 주기를 의미한다.
두 번째 파라미터는 반복 실행 여부를 결정한다.
세 번째 파라미터는 반복으로 사용할 코드를 전달한다.
Closure로는 세번째 파라미터로 전달된 코드를 호출한 timer를 전달한다.

위 상황 대로라면 timer의 isValid 속성이 true인 경우에만 코드를 실행하게 된다.

@IBAction func start(_ sender: Any) {
	timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true, block: { (timer) in
		guard timer.isValid else { return }
		self.updateTimer(timer)
	})
}

guard문의 조건을 만족하면 updateTimer 메서드를 호출해 로그를 출력하고, Label을 업데이트 할 수 있도록 한다.
위처럼 Timer의 scheduledTimer 메소드를 사용해 Timer를 생성하면 Runloop에 추가되고, Timer가 시작된다.
따라서 다음에 사용할 생성자를 사용하는 방식 대비 구현이 단순해진다는 장점이 있다.

@IBAction func stop(_ sender: Any) {
	timer?.invalidate()
}

stop 메서드에는 timer의 invalidate 메서드를 호출해 준다.
만약 scheduledTimer 메서드의 repeats 파라미터에 false를 전달했다면 해당 메서드를 호출하지 않아도 자동으로 호출한다.
하지만 지금처럼 true를 전달한 경우 적당한 때에 invalidate 메소드를 호출해 해제해야 한다.
해당 메서드가 호출되면 Runloop에서 해제되고, 해제된 Timer는 다시 사용할 수 없다.
따라서 다시 이어서 실행해야 한다면 새로운 Timer를 생성해야 한다.
또한, invalidate 메서드는 이전에 생성한 Thread와 동일한 Thread에서 호출해야 한다.

start를 누르면 Label이 업데이트됨과 동시에 콘솔에도 현재 시각이 출력된다.
stop을 누르면 정지된다.

이번엔 start를 두 번 누른다.
이번엔 로그가 두 개씩 출력되고, stop을 눌러도 정지되지 않는다.
먼저 생성된 Timer에 접근할 수 있는 방법이 존재하지 않기 때문에 동작을 완벽히 멈출 수 없게 된다.

이와 같은 문제를 막기 위해 Timer를 생성할 때는 이전에 생성된 Timer가 있는지 확인하고,
해당 Timer를 삭제한 뒤 생성해야 한다.

@IBAction func start(_ sender: Any) {
	guard timer == nil else { return }
	timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true, block: { (timer) in
		guard timer.isValid else { return }
		self.updateTimer(timer)
	})
}

코드를 위와 같이 수정하면 먼저 timer가 존재하는지 확인하고,
있다면 코드를 종료하고, 없다면 새로 생성하게 된다.

Timer 생성자로 만들기

생성자를 통해 Timer를 생성할 때에 사용하는 파라미터들도 유사하다.
이 중 userInfo를 파라미터는 Timer와 관련된 Custom 데이터를 저장할 때 사용한다.

이번엔 Selector를 사용하는 생성자를 사용한다.

@objc func timeFired(_ timer: Timer) {

}

따라서 전달할 메서드를 하나 생성한다.
해당 메서드는 Timer를 파라미터로 전달받는다.

@objc func timeFired(_ timer: Timer) {
	updateTimer(timer)
}

해당 메서드에는 마찬가지로 updateTimer 메서드를 호출하고,

@IBAction func start(_ sender: Any) {
	guard timer == nil else { return }
//		timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true, block: { (timer) in
//			guard timer.isValid else { return }
//			self.updateTimer(timer)
//		})
	timer = Timer(timeInterval: 1, target: self, selector: #selector(timeFired(_:)), userInfo: nil, repeats: true)
}

해당 메소드를 파라미터로 전달한다.
나머지 파라미터는 이전과 동일하게 설정했다.

하지만 이번엔 start를 눌러도 동작하지 않는다.
이는 Timer를 생성하기만 하고, 실행하지 않았기 때문으로,
생성자를 사용해 Timer를 생성하는 경우 Runloop에 추가하고, Fire 메소드를 직접 호출해야 한다.

@IBAction func start(_ sender: Any) {
	guard timer == nil else { return }
//		timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true, block: { (timer) in
//			guard timer.isValid else { return }
//			self.updateTimer(timer)
//		})
	timer = Timer(timeInterval: 1, target: self, selector: #selector(timeFired(_:)), userInfo: nil, repeats: true)
	RunLoop.current.add(timer!, forMode: .default)
	timer?.fire()
}

따라서 위와 같이 Runloop에 직접 Timer를 추가하고, fire 메서드를 호출해 시작하도록 변경하면 제대로 동작하는 것을 확인할 수 있다.

Scene 전환 간 Timer 관리

Timer를 시작한 뒤 홈 화면으로 나가면 Timer가 정지되고,
다시 복귀하면 이어서 동작을 시작한다.
하지만 이전 Scene으로 돌아가면 Timer는 유지되지 않고,
다시 복귀했을 때 새 화면에서 새 Timer를 생성한다.

이는 생성자에서 target으로 지정한 Self가 현재 View Controller가 아니기 때문이다.
이런 경우 화면에 진입했다가 나올 때마다 필요 없는 Timer들이 계속 늘어나게 된다.
따라서 화면을 벗어날 때 화면을 중지하는 것이 중요하다.

override func viewWillDisappear(_ animated: Bool) {
	super.viewWillDisappear(animated)
	
	timer?.invalidate()
	timer = nil
}

따라서 위와 같이 viewWillDisappear에서 Timer를 멈추고, 해제하도록 수정하면 이전 Scene으로 돌아가는 경우 Timer가 정지된다.

이번엔 우측 상단의 버튼을 눌러 새로운 화면을 띄워본다.
이 경우 viewWillDisappear 메서드가 호출돼 Timer가 정지되지만,
화면을 닫은 후에는 Label도 Timer도 반응이 없다.

override func viewWillAppear(_ animated: Bool) {
	super.viewWillAppear(animated)
	
	start(timer)
}

이때는 viewWillAppear 메서드에서 위와 같이 start 메소드에 timer를 전달해 새로운 timer를 생성해야 한다.
viewWillDisappear 메서드에서 종료한 Timer는 다시 사용할 수 없기 때문이다.

Timer Scene이 표시되면 자동으로 Timer가 시작된다.

Tolerance

@IBAction func start(_ sender: Any) {
	guard timer == nil else { return }
//		timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true, block: { (timer) in
//			guard timer.isValid else { return }
//			self.updateTimer(timer)
//		})
	timer = Timer(timeInterval: 1, target: self, selector: #selector(timeFired(_:)), userInfo: nil, repeats: true)
	timer?.tolerance = 0.2
	RunLoop.current.add(timer!, forMode: .default)
	timer?.fire()
}

앞서 언급했듯 Timer는 약간의 오차를 가지고 있다.
따라서 위와 같이 tolerance를 성정해 주면 iOS가 이를 오차로 판단해 최적화한다.
따라서 터치에 대한 반응성 향상과 너 나아가 배터리를 절약할 수 있게 되기도 한다.

 

Concurrency Programming

Concurrency는 어렵다.
강의에서 필요한 기초적인 내용을 다룬다.

개요

Concurrency Programming은 여러 개의 작업이 동시에 실행되도록 프로그램을 개발하는 방법이다.
모바일 환경에서는 작업을 빠르게 실행하는 것도 중요하지만 타이밍을 조절해 반응성을 높이는 것도 매우 중요하다.
CocoaTouch Framework는 이러한 방식에 사용할 수 있는 Operation과 GCD를 제공한다.
둘을 활용하면 HW를 최대한으로 사용해 빠르고 반응성이 높은 앱을 만들 수 있다.

앱을 시작하면 새로운 App 인스턴스가 생성되는데 이것을 Process라고 부른다.
iOS는 이 Process를 실행하고, 이에 필요한 자원을 할당한다.

앱에서 실행되는 작업을 Task라고 부른다.
Process 내에는 이 Task를 실행하는 하나 이상의 Thread가 존재한다.
앱에서 여러 개의 작업을 동시에 실행할 수 있는 것은 Thread가 자원을 적절히 분배해 Task를 실행하기 때문이다.

Main Thread, Background Thread

iOS의 앱을 시작하면 Main Thread라는 Thread가 자동으로 생성된다.
UI를 업데이트하는 코드와 Touch 이벤트를 처리하는 코드는 반드시 Main Thread에서 실행해야 한다.
만약 60 fps로 동작하는 앱이라면 이론적으로 0.016초 이내에 필요한 코드를 실행하고 반환해야 한다.
최근의 iOS 기기들 같이 120 fps를 지원한다면 0.008초가 된다.
기기의 성능이 그만큼 좋아졌기 때문에 대부분의 코드는 제한시간을 넘기지 않을 수 있다.

네트워크를 사용하거나 파일을 불러오는 작업은 Background Thread에서 실행해야 한다.
Main Thread에서도 이를 실행할 수 있지만 반응성이 눈에 띄게 저하된다.
Background Thread는 Main Thread와 달리 필요할 때마다 직접 추가해야 한다.

iOS 앱을 개발할 때는 Thread를 직접 다루지 않는다.
경험이 부족하다면 이를 올바르게 사용하는 것조차 버거워 오류 발생의 가능성이 높고,
이를 활용하지 않았을 때 보다 성능이 떨어질 가능성도 있다.
따라서 대부분의 어렵고 복잡한 부분들은 알아서 처리되도록 구현돼있다.
따라서 Operation과 GCD를 익혀 실행할 작업을 구현하고,
이를 원하는 Thread에 추가하는 방법만 알고 있으면 문제가 없다.

Queue

Concurrency Programming에 관련된 자료를 보면 Queue라는 용어를 자주 볼 수 있다.
Queue는 Thread에서 사용할 작업을 저장하는 요소이다.
앞에서 설명한 규칙에 적용하면
UI 업데이트와 Touch 이벤트를 처리하는 코드는 Main Thread에서 실행되도록 Main Queue에 추가한다.
Main Thread가 그렇듯, Main Queue도 생성할 필요 없이 API를 활용해 바로 사용할 수 있다.
또한 일반적인 작업을 위한 Global Queue도 제공한다.
혹은 필요에 의해 Backgrounf Queue를 생성할 수도 있다.

iOS 앱 개발에서는 Operation과 GCD를 사용한다.
Operation은 작업들 사이에 의존성을 구현하거나 취소 기능을 구현하는 경우 사용한다.
이외의 경우엔 GCD를 사용한다.

GCD는 Grand Central Dispatch로 실행할 작업을 원하는 Queue에 추가해 두면 Multi Core를 통해 최대한 빠르게 실행한다.
Operation 또한 내부적으로는 이 GCD를 활용한다.

Concurrency Programming을 사용하지 않을 때의 문제

//
//  StartViewController.swift
//  Concurrency Practice
//
//  Created by Martin.Q on 2021/12/22.
//

import UIKit

class StartViewController: UIViewController {
	
	@IBOutlet weak var countLabel: UILabel!
	
	@IBAction func start(_ sender: Any) {
		countLabel.text = "0"
		
		for count in 0...100 {
			countLabel.text = "\(count)"
		}
	}
	
	func logThread(with task: String) {
		if Thread.isMainThread {
			print("Main Thread: \(task)")
		} else {
			print("Background Thread: \(task)")
		}
	}
	
	
    override func viewDidLoad() {
        super.viewDidLoad()

    }

}

사용할 Scene과 코드는 위와 같다.
start 버튼은 start 메서드와 연결돼 Label을 반복적으로 업데이트한다.
이렇게 Action으로 연결된 코드는 항상 Main Thread에서 실행된다.
다른 메서드들도 따로 Thread를 지정하지 않으면 마찬가지로 Main Thread에서 실행된다.

시뮬레이터를 확인해 보면 start를 누른 직후 바로 100을 표시한다.
이는 실행하는 코드가 굉장히 짧은 시간 내에 완료되기 때문이다.

@IBAction func start(_ sender: Any) {
	countLabel.text = "0"
	
	for count in 0...100 {
		countLabel.text = "\(count)"
		Thread.sleep(forTimeInterval: 0.1)
	}
}

따라서 약간의 지연 시간을 추가한다.
앞서 설명했듯 Thread를 직접 다루는 대신 API를 사용하여 잠시 멈추거나 재시작하는 동작은 가능하다.

하지만 이 경우 Label 업데이트가 지연되는 것이 아닌 start 메서드가 반환 될 때 가지 Label이 업데이트 되지 않는다.
또한 Button과 Switch가 제대로 반응하지 않는다.
메소드 실행에 필요한 시간은 0.1 * 100으로 10초에 달하고,
이 시간 동안 Main Thread를 점유하고 있기 때문에 다른 이벤트를 처리하지 못한다.
이 경우를 'Main Thread가 Blocking 됐다.'라고 표현하기도 한다.
이 경우 Background Thread를 사용해야 Blocking을 막을 수 있다.

@IBAction func start(_ sender: Any) {
	countLabel.text = "0"
	
	DispatchQueue.global().async {
		for count in 0...100 {
			self.countLabel.text = "\(count)"
			Thread.sleep(forTimeInterval: 0.1)
		}
	}
}

이런 식으로 Background Thread에서 실행되도록 수정하면 async로 전달된 코드는 Background에서 실행된다.
실제 실행 시점은 시스템이 자동으로 결정한다.

하지만 실제로 동작시키면 오류가 발생하는데,
이는 앞에서 설명한 대로 UI 업데이트와 관련된 코드가 Background에서 실행됐기 때문으로,
해당 코드는 반드시 Main Thread에서 실행해야 한다.

@IBAction func start(_ sender: Any) {
	countLabel.text = "0"
	
	DispatchQueue.global().async {
		for count in 0...100 {
			DispatchQueue.main.async {
				self.countLabel.text = "\(count)"
			}
			Thread.sleep(forTimeInterval: 0.1)
		}
	}
}

따라서 코드를 다시 위와 같이 수정한다.
main.async로 전달되는 코드는 Main Thread에서 실행되게 된다.
Background에서 구동되는 코드에서 UI를 업데이트해야 하는 경우 사용되는 패턴이다.

이제는 Label을 정상적으로 업데이트하면서 Touch 이벤트로 처리할 수 있게 됐다.

iOS의 앱은 항상 이런 방식으로 구현한다.
오래 걸리는 동작들은 Background Thread에서 실행하고,
UI를 업데이트하거나 Touch 이벤트를 처리하는 경우 Main Thread에서 실행하도록 구현한다.

func logThread(with task: String) {
	if Thread.isMainThread {
		print("Main Thread: \(task)")
	} else {
		print("Background Thread: \(task)")
	}
}

해당 메서드는 isMainThread에 저장된 값에 따라 로그를 출력하도록 구현돼있다.
메서드의 코드가 Main Thread에서 실행되면 isMainThread에는 true가 저장된다.

@IBAction func start(_ sender: Any) {
	countLabel.text = "0"
	
	logThread(with: "Start")
	
	DispatchQueue.global().async {
		for count in 0...3 {
			self.logThread(with: "for #\(count)")
			DispatchQueue.main.async {
				self.logThread(with: "update label")
				self.countLabel.text = "\(count)"
			}
			Thread.sleep(forTimeInterval: 0.1)
		}
		self.logThread(with: "Done")
	}
	self.logThread(with: "End")
}

주요 시점마다 logThread 메서드를 호출하고, 결과를 보기 쉽도록 반복 횟수를 조정한다.

로그를 확인하면 Main Thread에서 Start 다음 End가 출력됐다.
이는 async의 특성으로 실행할 코드를 전달한 다음 바로 반환한다.
때문에 블록의 마지막에 있는 End가 바로 출력되고, 이어서 start 메서드가 실행된다.

이어서 Background Thread에서 반복문이 실행되고,
Label을 업데이트할 때는 Main Thread에서 진행된다.
작업이 완료되면 Background Thread에서 Done이 출력되고, 작업이 종료된다.

'학습 노트 > iOS (2021)' 카테고리의 다른 글

172. Interoperation Dependencies  (0) 2022.01.07
171. Operation & Operation Queue  (0) 2022.01.06
168. Debugging Auto Layout  (0) 2021.12.21
167. Constraints with Code #5  (0) 2021.12.21
166. Constraints with Code #4  (0) 2021.12.20