관리 메뉴

김종권의 iOS 앱 개발 알아가기

[iOS - swift] 2. Timer 구현하기 - DispatchSourceTimer로 구현 방법 (Background에서도 동작) 본문

iOS 응용 (swift)

[iOS - swift] 2. Timer 구현하기 - DispatchSourceTimer로 구현 방법 (Background에서도 동작)

jake-kim 2021. 11. 24. 03:14

1. Timer 구현하기 - UIDatePicker 개념, Timer로 구현 방법

2. Timer 구현하기 - DispatchSourceTimer로 구현 방법 (Background에서도 동작)

3. Timer 구현하기 - CircularProgressBar UI 구현(CAShapeLayer 사용), DispatchSourceTimer와 Timer로 구현 방법

Background에서도 동작하고 있는 Timer
1분이 지나면 완료 closure 실행

UIDatePicker UI

import UIKit

class ViewController: UIViewController {
    
    lazy var countDownDatePicker: UIDatePicker = {
        let picker = UIDatePicker()
        picker.datePickerMode = .countDownTimer
        return picker
    }()
    
    lazy var buttonContainerStackView: UIStackView = {
        let view = UIStackView()
        view.axis = .vertical
        view.spacing = 16
        return view
    }()
    
    lazy var startRepeatTimerButton: UIButton = {
        let button = UIButton()
        button.setTitle("타이머 시작", for: .normal)
        button.setTitleColor(.systemBlue, for: .normal)
        button.setTitleColor(.blue, for: .highlighted)
        return button
    }()
    
    lazy var resumeRepeatTimerButton: UIButton = {
        let button = UIButton()
        button.setTitle("타이머 재개", for: .normal)
        button.setTitleColor(.systemBlue, for: .normal)
        button.setTitleColor(.blue, for: .highlighted)
        button.isHidden = true
        return button
    }()
    
    lazy var suspendTimerButton: UIButton = {
        let button = UIButton()
        button.setTitle("일시정지", for: .normal)
        button.setTitleColor(.systemBlue, for: .normal)
        button.setTitleColor(.blue, for: .highlighted)
        button.isHidden = true
        return button
    }()
    
    lazy var cancelTimerButton: UIButton = {
        let button = UIButton()
        button.setTitle("취소", for: .normal)
        button.setTitleColor(.systemBlue, for: .normal)
        button.setTitleColor(.blue, for: .highlighted)
        return button
    }()
    
    lazy var countDownLabel: UILabel = {
        let label = UILabel()
        label.textColor = .black
        label.font = .systemFont(ofSize: 24)
        return label
    }()

    override func viewDidLoad() {
        super.viewDidLoad()

        setupViews()
        addSubviews()
        setupLayout()
    }
    
    private func setupViews() {
        view.backgroundColor = .systemBackground
    }
    
    private func addSubviews() {
        view.addSubview(countDownDatePicker)
        view.addSubview(buttonContainerStackView)
        buttonContainerStackView.addArrangedSubview(startRepeatTimerButton)
        buttonContainerStackView.addArrangedSubview(resumeRepeatTimerButton)
        buttonContainerStackView.addArrangedSubview(suspendTimerButton)
        buttonContainerStackView.addArrangedSubview(cancelTimerButton)
        view.addSubview(countDownLabel)
    }
    
    private func setupLayout() {
        countDownDatePicker.translatesAutoresizingMaskIntoConstraints = false
        countDownDatePicker.topAnchor.constraint(equalTo: view.topAnchor, constant: 100).isActive = true
        countDownDatePicker.centerXAnchor.constraint(equalTo: view.centerXAnchor).isActive = true
        
        buttonContainerStackView.translatesAutoresizingMaskIntoConstraints = false
        buttonContainerStackView.topAnchor.constraint(equalTo: countDownDatePicker.bottomAnchor, constant: 24).isActive = true
        buttonContainerStackView.centerXAnchor.constraint(equalTo: view.centerXAnchor).isActive = true
        
        countDownLabel.translatesAutoresizingMaskIntoConstraints = false
        countDownLabel.topAnchor.constraint(equalTo: buttonContainerStackView.bottomAnchor, constant: 24).isActive = true
        countDownLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor).isActive = true
    }
    
    @objc func didTapRepeatTimerButton() {
    }
    
    @objc func didTapResumeRepeatTimerButton() {
    }
    
    @objc func didTapSuspendTimerButton() {
    }
    
    @objc func didTapCancelTimerButton() {
    }
    
    
}

DispatchSourceTimer를 이용한 Timer 모듈 구현

  • interface 정의
    • timerState: 타이머의 상태에 따라 UI버튼에 변화를 주기 위한 값
    • start(): 타이머 시작 
    • resume(): 타이머 재개
    • suspend(): 타이머 일시 중지
    • cancel(): 타이머 취소 (타이머 다시 시작 가능)
enum TimerState {
    case suspended
    case resumed
    case canceled
    case finished
}

protocol RepeatingSecondsTimer {
    var timerState: TimerState { get }
    
    func start(durationSeconds: Double,
               repeatingExecution: (() -> Void)?,
               completion: (() -> Void)?)
    func resume()
    func suspend()
    func cancel()
}

Timer 구현

  • 아이디어
    • DispatchSourceTimer인스턴스 2개 사용
      (각각 setEventHandler로 timer가 종료된 경우 실행하는것과 timer가 매초마다 동작해야하는 로직을 위해 2개 필요)
    • schedule(deadline:): deadline이 되면 최초 한번 실행하는 메소드 
    • schedule(deadline:repeating:): deadline부터 repeating초마다 실행
    • setEventHandler(completion:): 이벤트 처리 등록
  • interface 구현체 정의
final class RepeatingSecondsTimerImpl: RepeatingSecondsTimer {

}
  • 프로퍼티 정의
    • timerState: 외부에서 timer의 상태를 확인하여 UI업데이트를 위한 값
    • repeatingExecution: 매초마다 실행되는 클로저
    • completion: 지정된 시간이 지나서 타이머가 종료된 경우 실행할 클로저
    • timers: 매초 실행되는 timer 인스턴스와 마지막에 실행될 timer 인스턴스를 가지고 있는 변수
var timerState = TimerState.suspended
private var repeatingExecution: (() -> Void)?
private var completion: (() -> Void)?
private var timers: (repeatTimer: DispatchSourceTimer?, nonRepeatTimer: DispatchSourceTimer?) = (DispatchSource.makeTimerSource(),
                                                                                                 DispatchSource.makeTimerSource())
  • start 정의
    • 내부적으로 정의한 setTimer(), resume() 메소드를 호출
      • setTimer()에서는 timer객체에 시간과 completion 할당
      • resume()에서는 timer 가동
func start(durationSeconds: Double,
           repeatingExecution: (() -> Void)? = nil,
           completion: (() -> Void)? = nil) {
    setTimer(durationSeconds: durationSeconds,
             repeatingExecution: repeatingExecution,
             completion: completion)
    
    resume()
}
  • setTimer(durationSeconds:repeatingExecution:completion:)
    • 핵심 코드이며, 지속적으로 실행되는 것과 타이머 종료 시 실행되는 것 두 기능을 위해 두 가지 Timer 인스턴스 사용
private func setTimer(durationSeconds: Double,
                      repeatingExecution: (() -> Void)? = nil,
                      completion: (() -> Void)? = nil) {
    initTimer()
    
    self.repeatingExecution = repeatingExecution
    self.completion = completion
    
    timers.repeatTimer?.schedule(deadline: .now(), repeating: 1)
    timers.repeatTimer?.setEventHandler(handler: repeatingExecution)
    
    timers.nonRepeatTimer?.schedule(deadline: .now() + durationSeconds)
    timers.nonRepeatTimer?.setEventHandler { [weak self] in
        self?.finish()
        completion?()
    }
}
  • 인스턴스가 해제될 때 timer도 같이 해제되도록 설계
deinit {
    removeTimer()
}

private func removeTimer() {
    // cancel()을 한번 실행하면 timer를 다시 사용할 수 없는 상태임을 주의
    // cancel()을 할땐 resume()을 호출한 후 해야 크래시가 나지 않고 정상 취소가 가능
    timers.repeatTimer?.resume()
    timers.nonRepeatTimer?.resume()
    timers.repeatTimer?.cancel()
    timers.nonRepeatTimer?.cancel()
    initTimer()
}

private func initTimer() {
    timers.repeatTimer?.setEventHandler(handler: nil)
    timers.nonRepeatTimer?.setEventHandler(handler: nil)

    repeatingExecution = nil
    completion = nil
}
  • 재개 / 일시정지 / 취소 / 종료
func resume() {
    guard timerState == .suspended else { return }
    timerState = .resumed
    timers.repeatTimer?.resume()
    timers.nonRepeatTimer?.resume()
}

func suspend() {
    guard timerState == .resumed else { return }
    timerState = .suspended
    timers.repeatTimer?.suspend()
    timers.nonRepeatTimer?.suspend()
}

func cancel() {
    timerState = .canceled
    initTimer()
}

private func finish() {
    timerState = .finished
    cancel()
}
  • 전체 RepeatingTimerImpl 코드
import Foundation

enum TimerState {
    case suspended
    case resumed
    case canceled
    case finished
}

protocol RepeatingSecondsTimer {
    var timerState: TimerState { get }
    
    func start(durationSeconds: Double,
               repeatingExecution: (() -> Void)?,
               completion: (() -> Void)?)
    func resume()
    func suspend()
    func cancel()
}

final class RepeatingSecondsTimerImpl: RepeatingSecondsTimer {
    
    var timerState = TimerState.suspended
    private var repeatingExecution: (() -> Void)?
    private var completion: (() -> Void)?
    private var timers: (repeatTimer: DispatchSourceTimer?, nonRepeatTimer: DispatchSourceTimer?) = (DispatchSource.makeTimerSource(),
                                                                                                     DispatchSource.makeTimerSource())
    
    deinit {
        removeTimer()
    }
    
    func start(durationSeconds: Double,
               repeatingExecution: (() -> Void)? = nil,
               completion: (() -> Void)? = nil) {
        setTimer(durationSeconds: durationSeconds,
                 repeatingExecution: repeatingExecution,
                 completion: completion)
        
        resume()
    }
    
    func resume() {
        guard timerState == .suspended else { return }
        timerState = .resumed
        timers.repeatTimer?.resume()
        timers.nonRepeatTimer?.resume()
    }

    func suspend() {
        guard timerState == .resumed else { return }
        timerState = .suspended
        timers.repeatTimer?.suspend()
        timers.nonRepeatTimer?.suspend()
    }

    func cancel() {
        timerState = .canceled
        initTimer()
    }

    private func finish() {
        timerState = .finished
        cancel()
    }
    
    private func setTimer(durationSeconds: Double,
                          repeatingExecution: (() -> Void)? = nil,
                          completion: (() -> Void)? = nil) {
        initTimer()
        
        self.repeatingExecution = repeatingExecution
        self.completion = completion
        
        timers.repeatTimer?.schedule(deadline: .now(), repeating: 1)
        timers.repeatTimer?.setEventHandler(handler: repeatingExecution)
        
        timers.nonRepeatTimer?.schedule(deadline: .now() + durationSeconds)
        timers.nonRepeatTimer?.setEventHandler { [weak self] in
            self?.finish()
            completion?()
        }
    }
    
    private func initTimer() {
        timers.repeatTimer?.setEventHandler(handler: nil)
        timers.nonRepeatTimer?.setEventHandler(handler: nil)

        repeatingExecution = nil
        completion = nil
    }
    
    private func removeTimer() {
        // cancel()을 한번 실행하면 timer를 다시 사용할 수 없는 상태임을 주의
        // cancel()을 할땐 resume()을 호출한 후 해야 크래시가 나지 않고 정상 취소가 가능
        timers.repeatTimer?.resume()
        timers.nonRepeatTimer?.resume()
        timers.repeatTimer?.cancel()
        timers.nonRepeatTimer?.cancel()
        initTimer()
    }
}

사용하는 쪽

  • ViewController 준비
class ViewController: UIViewController {

}

 

  • repeatingSecondsTimer 모듈 선언
private let repeatingSecondsTimer: RepeatingSecondsTimer

init(repeatingSecondsTimer: RepeatingSecondsTimer) {
    self.repeatingSecondsTimer = repeatingSecondsTimer
    super.init(nibName: nil, bundle: nil)
}
  • 타이머 시작 버튼을 누른 경우 실행되는 메소드 정의
    • 타이머가 실행중인 경우에는 +1씩 UI 업데이트
    • 타이머가 종료되었을 때는 "타이머 완료" UI 업데이트
var time = 0
private func startRepeatTimer() {
    repeatingSecondsTimer.start(durationSeconds: countDownDatePicker.countDownDuration) {
        DispatchQueue.main.async {
            self.time += 1
            self.countDownLabel.text = "타이머 = \(self.time)"
        }
    } completion: {
        DispatchQueue.main.async { [weak self] in
            self?.countDownLabel.text = "타이머 완료"
        }
    }
}
  • 각 버튼을 누른 경우 Timer 동작
@objc func didTapRepeatTimerButton() {
    startRepeatTimer()
    setTimerButtonsUsingTimerState()
}

@objc func didTapResumeRepeatTimerButton() {
    repeatingSecondsTimer.resume()
    setTimerButtonsUsingTimerState()
}

@objc func didTapSuspendTimerButton() {
    repeatingSecondsTimer.suspend()
    setTimerButtonsUsingTimerState()
}

@objc func didTapCancelTimerButton() {
    repeatingSecondsTimer.cancel()
    setTimerButtonsUsingTimerState()
}

private func setTimerButtonsUsingTimerState() {
    
    switch repeatingSecondsTimer.timerState {
    case .resumed:
        startRepeatTimerButton.isHidden = true
        suspendTimerButton.isHidden = false
        resumeRepeatTimerButton.isHidden = true
    case .suspended:
        startRepeatTimerButton.isHidden = true
        suspendTimerButton.isHidden = true
        resumeRepeatTimerButton.isHidden = false
    case .canceled:
        startRepeatTimerButton.isHidden = false
        suspendTimerButton.isHidden = true
        resumeRepeatTimerButton.isHidden = true
        countDownLabel.text = "타이머 취소"
        time = 0
    case .finished: break
    }
}

* 전체 소스 코드: https://github.com/JK0369/ExTimerModule

Comments