[Android] Flow, Job 을 이용한 Timer 구현하기

Sehee Jeong·2022년 8월 14일
0

"안드로이드 타이머 구현하기" 로 구글링해보면 대부분 내장되어있는 Timer 클래스를 이용하는 방법에 대해 소개한다. Timer 클래스를 이용하면 간단하게 타이머를 구현할 수 있겠지만 입맛대로 커스텀하기 힘들다는 단점이 있는 듯 하다. 그래서 이번 포스팅에서는 Timer 클래스를 flow + coroutine 으로 구현해 내가 원하는 조건을 충족할 수 있도록 구현해보고자 한다.

Condition

내가 원하는 타이머의 조건은 아래와 같다.

  1. 타이머 클래스는 singleton 이다.

  2. 외부에서도 타이머 시작을 트리거 할 수 있도록 한다.
    보통 타이머 클래스를 설명하는 블로그 포스팅을 보면 타이머를 시작하는 View Controller(Activity, Fragment) 에서 버튼을 만들어 누르면 타이머가 시작되도록 설계되어있다. 하지만 나는 동일한 화면 내에서 타이머를 시작, 재개하는 것도 포함하여 다른 화면, 다른 클래스, 혹은 유저의 어떠한 행위로 인해 타이머가 구현된 클래스가 시작될 수 있도록 하고자 한다.

  3. 특정 행위(A)를 트리거해 타이머 일시중지 한다.
    간단하게 표현하고자 "특정 행위"라고 표현했지만, 대상에 따라 1번과 동일하게 클래스, 화면 등등 트리거 할 수 있는 다양한 것이 될 수 있다.

  4. 특정 행위(B)를 트리거해 일시중지 했던 시간부터 타이머를 다시 재개한다.

  5. 특정 시간이 지나면 타이머는 종료된다.

  6. 타이머 상태가 불규칙하게 들어와도 타이머는 무조건 하나만 실행되어야 한다.
    6번의 대한 자세한 설명은 Timer State 를 정의하면서 설명하겠다.



Implementation

Timer State 정의

    sealed class TimerState {
    	object UnInitialized: TimerState()
        object Ready: TimerState()
        object Start: TimerState()
        object Pause: TimerState()
        object Finish: TimerState()
    }

타이머 상태를 정의한다.

  1. 타이머 클래스 인스턴스 생성 전 UnInitialized 상태
  2. 타이머 클래스 인스턴스를 생성하라는 signal 이 될 수 있는 Ready 상태
  3. 타이머를 시작하는 Start 상태 (== resume 과 동일하게 사용할 수도 있고, 혹은 resume 상태를 별도로 정의해도 좋다.)
  4. 타이머를 일시중지하는 Pause 상태
  5. 타이머를 종료하는 Finish 상태

앞서 Condition 6번에 대해 설명하자면, Timer State로 타이머를 시작 및 정지할 수 있는데 State 가 무작위로 들어와도 무조건 한 개의 타이머만 실행되고 종료되어야 한다는 의미이다.

예시로 특정 행위로 인해 Timer State 가 Ready -> Start -> Start -> Start -> Pause -> Finish 가 차례대로 들어왔다고 가정해보자. 그렇다면 가장 처음 들어왔던 Start 상태만 유효한 것이다.
다른 예시로 Ready -> Pause -> Start -> Finish 상태가 들어왔을 때는, Pause 상태는 무시되고 Start 상태가 들어왔을 때부터 타이머가 시작될 것이다.

Start 상태가 계속 들어왔다고 해서 타이머를 하나 더 생성하거나, 타이머 시간이 초기화되면 안되며 Start 상태를 받기 전에 Pause, Finish 상태를 받아도 에러없이 동작하는 것이 커스텀 타이머의 조건이다.

또한, 각 상태가 object 로만 선언되어 있지만 특정 데이터를 보내야 하는 경우도 있을 수 있기에 data class 로도 변경하여 사용해도 좋다.

Timer Class 정의

모든 코드를 공개하는 것보다 큰 흐름단위로 설명하고자 한다.
(작은 흐름단위는 생략되어있음을 감안하고 읽어주기를 바란다.)

    private var timerJob: Job = Job()
    private val coroutineScope = CoroutineScope(Dispatchers.Main)
... etc.
    val timerFlow = MutableStateFlow<TimerState>(TimerState.UnInitialized)
  1. 타이머는 하나의 Job 으로 관리한다.
    이유는, 원하는 시점에 Job 을 start or cancel 하고 싶었기 때문이다. 외부 행위로 인해 들어온 Timer State 를 이용해 원하는 시점에 타이머를 시작 및 중지하기 위해서는 Job 이 간단하면서, 제일 적당하다고 생각했다.

  2. 앞으로 정의되는 함수들을 하나의 coroutine scope로 관리하기 위해 정의한다.

  3. 외부로부터 state 를 emit, collect 할 수 있는 timerFlow 를 정의한다. timerFlow 는 Timer class를 포함하여 class 를 정의한 외부에서도 모두 사용할 수 있다.


    fun setTimerStateFlow(state: TimerState) = coroutineScope.launch {
        timerFlow.emit(state)
        
        when (state) {
            is TimerState.Ready -> initTimerMillis() // 타이머 시간 설정
            is TimerState.Start -> startTimerJob() // 타이머 시작 or 재개
            is TimerState.Pause -> pauseTimerJob() // 타이머 일시중지
            is TimerState.End -> stopTimerJob() // 타이머 종료
        }
    }
   
// @Inject val customTimer: CustomTimer ...
customTimer.timerFlow.collect { state ->
    when(state) { ... }
}

setTimerStateFlow 함수를 이용하여 타이머 상태를 설정하며 설정된 상태를 이용해 타이머를 시작 혹은 중지할 수 있다. 동시에 Timer class 를 이용하는 다른 클래스에서 timerFlow 를 observe 해 Timer State 에 따른 행위를 정의할 수 있다.


이제 Timer State 를 이용한 타이머 시작과 일시중지 코드에 대해 설명하겠다.


Start Timer

    private fun startTimerJob() {
        val startTime = System.currentTimeMillis()
        
        if (timerJob.isActive.not()) {
            timerJob = coroutineScope.launch(start = CoroutineStart.LAZY) {
                while (elapsedTimeMillis < 5000L) { // 5초 뒤 타이머 종료
                    elapsedTimeMillis = (System.currentTimeMillis() - startTime) + lastResumedTime
                    delay(1000L) // 1초씩 타이머 계산
                }
            }
        }
    }

타이머 시작을 정의하는 함수이다.

job 이 active 상태가 아닐 때만 실행할 수 있도록 정의한다. (LAZY 로 설정했기 때문에 timerJob 은 생성 당시 무조건 new 상태이다.)

또한 해당 함수는 lastResumedTime 변수를 사용하여 Resume 상태로도 사용될 수 있다. 타이머가 정지되었을 때, 시작부터 정지되었을 때까지 흐른 시간을 lastResumedTime 로 담아 저장하는데 startTimerJob() 이 다시 호출될 때, 정지된 시간(이 바로 lastResumedTime)부터 다시 타이머를 재개하기 때문이다.


Pause Timer

    private fun pauseTimerJob() {
        if (timerJob.isActive) {
            timerJob.cancel()
           ...
        }
    }

타이머 일시중지를 정의하는 함수이다.

timerJob 이 isActive 상태인 경우에는 바로 Job 을 cancel 한다.
타이머 시작 함수에서 볼 수 있겠지만, Job을 lazy 로 선언했기 때문에 active 상태인 경우는 타이머가 시작했을 때 밖에 없다. 타이머가 시작되기 전에 pauseTimer 가 불리거나, 타이머가 종료된 이후에 pauseTimer 가 불리는 경우, 예상치 못한 이슈를 캐치하기 위해 active 상태를 검증한다.

타이머 종료 함수도 이들과 비슷하기에 설명은 패스 = )

Timer Usage Example

간단한 예시를 들어보겠다.
내가만든 앱에 메인화면과 상세화면이 있다고 가정해보자. 나는 유저가 메인화면에 머무는 시간의 총 합이 10초를 넘기는 경우 이름과 함께 Log 를 추가하고 싶다. 그래서 메인화면 진입 시 timer 를 시작하는 코드를 추가했다. (timer 10초 설정은 이미 되어있다고 가정한다.)

//onResume
customTimer.setTimerStateFlow(TimerState.Start)

//onViewCreated..
customTimer.timerFlow.collect { state ->
    if (state == TimerState.End) addLog(fragment.name)
 }

그런데 유저가 메인 화면에서 바로 상세화면에 진입하고, 다시 메인화면으로 돌아오는 케이스에 대해서 이미 타이머가 종료되는 문제가 있었다. 타이머 클래스 자체가 싱글톤이다 보니 유저가 상세화면을 진입할 때도 타이머가 흐르고 있었던 것이다. 그래서 나는 유저가 상세화면에 진입할 때는 timer 를 중지하는 코드를 추가하였고, 타이머는 정상적으로 동작하게 되었다.

//onPause
customTimer.setTimerStateFlow(TimerState.Pause)


End

customTimer를 flow repeatOnLifecycle 개념처럼 생각해 구성해보았다.
타이머에 대한 기능만을 모아 클래스로 분리하고, 타이머 상태로 인한 클래스 변화를 주고 싶을 때 timerFlow 를 사용하여 업데이트 할 수 있도록 해보았는데, 조금만 더 머리를 굴려본다면 다양한 사용법으로도 확장할 수 있을 것 같다. 나는 아직 coroutine, flow, job 사용이 미숙해(...😮‍💨) 오기로라도 Timer class 를 사용하지 않고 구현해보았는데, 역시 아직도 어렵다 ~


profile
android developer @bucketplace

1개의 댓글

comment-user-thumbnail
2023년 2월 2일

좋은글 감사드립니다. 읽고 조금더 인사이트를 넓히게 되었습니다!

답글 달기