DevLog : Timer(Step.2)

Skele·2025년 4월 12일
0

Timer

뽀모도로용 타이머 클래스를 만들어보고자 한다.
카운트 다운 타이머로, 액티비티나 서비스 모두에 사용가능하도록 독립된 클래스로 설계하려고 했다.

구현

TimerController

타이머 상태를 StateFlow로 노출하고, 시작, 일시정지, 재개, 리셋 기능을 가진다.

/**
 * Controller for the timer.
 */
interface TimerController {
    val timerState: StateFlow<TimerState>

    /**
     * Initializes the timer to the given time.
     * Calling this on any state will reset the timer and be [TimerState.Idle].
     * @param time The total time of timer in milliseconds.
     */
    fun setTimer(time: Long)

    /**
     * Starts the timer.
     * Changes the state to [TimerState.Running].
     */
    fun start()

    /**
     * Pauses the timer.
     * Changes the state to [TimerState.Paused].
     */
    fun pause()

    /**
     * Resumes the timer.
     * Changes the state to [TimerState.Running].
     */
    fun resume()

    /**
     * Resets the timer.
     * Changes the state to [TimerState.Idle].
     */
    fun reset()
}

TimerData

타이머의 현재 시간 값을 저장하는 데이터 클래스.
현재 남은 시간과, 타이머가 설정된 시간을 저장한다.
totalTime을 가지고 있음으로서, 리셋을 간편하게 할 수 있다.

data class TimerData(
    val remainingTime: Long = 0,
    val totalTime: Long = 0,
)

TimerState

타이머 상태를 data로 분리하고 sealed interface로 각 상태를 표현하였다.
enum대신 sealed interface를 사용한 이유는:
1. 각 상태에 따라 다른 멤버를 가질 수 있기 때문에 확장성이 좋다.
2. when문으로 default를 주지 않아도 되어서 코드가 깔금해진다. (enum은 모든 값을 when문으로 명시하더라도 default를 요구한다. )
3. 현재 상태를 확인함에 있어서 is를 사용한 타입 체크가 간편하다.

다만, sealed interface에도 단점은 존재하는데, 상태값을 변경할 때 .copy를 사용할 수 없다는 문제가 있다. 각 인터페이스를 상속한 클래스는 서로 다른 클래스이므로.
하지만 data로 공통된 변수값을 묶어서 저장함으로써, 다른 상태값으로 변경할 때 TimerState.Running(data = state.data.copy(...)) 처럼 타이머 값을 .copy해서 넘겨주는 방식으로 간편하게 처리할 수 있다.

sealed interface TimerState {
    val data: TimerData

    /**
     * Initial state of the timer.
     */
    data class Idle(
        override val data: TimerData,
    ) : TimerState

    /**
     * Timer in progress.
     */
    data class Running(
        override val data: TimerData,
    ) : TimerState

    /**
     * Timer is stopped.
     */
    data class Paused(
        override val data: TimerData,
    ) : TimerState

    /**
     * Timer is finished.
     */
    data class Finished(
        override val data: TimerData,
    ) : TimerState
}

val TimerState.remainingTime get() = data.remainingTime
val TimerState.totalTime get() = data.totalTime

확장 변수로 state.data.remainingTime으로 접근할 코드를 조금이나마 줄일 수 있다.

TimerController Implementation

타이머 인터페이스의 구현체.
타이머 자체는 코루틴을 활용하고 Job을 가지고 있음으로써 타이머의 진행 상태를 관리한다.
또한, 생성자에 코루틴 스코프와 디스패처를 주입해줌으로서 테스트를 보다 용이하게 할 수 있도록 하였다.

class DefaultTimerController @Inject constructor(
    private val scope: CoroutineScope,
    private val dispatchersProvider: DispatchersProvider,
) : TimerController {
    private val _timerState = MutableStateFlow<TimerState>(TimerState.Idle(TimerData()))

    override val timerState: StateFlow<TimerState>
        get() = _timerState.asStateFlow()

    private var timerJob: Job? = null

    override fun setTimer(time: Long) {
        timerJob?.cancel()

        _timerState.value = TimerState.Idle(TimerData(totalTime = time, remainingTime = time))
    }

    override fun start() {
        if (timerJob?.isActive == true || _timerState.value is TimerState.Finished) return

        timerJob?.cancel()
        timerJob =
            scope.launch(dispatchersProvider.default) {
                _timerState.value = TimerState.Running(_timerState.value.data)

                while (_timerState.value is TimerState.Running && _timerState.value.remainingTime > 0) {
                    delay(100L)
                    _timerState.update {
                        val remainingTime = (it as TimerState.Running).data.remainingTime - 100L

                        if (remainingTime <= 0) {
                            TimerState.Finished(it.data.copy(remainingTime = 0))
                        } else {
                            TimerState.Running(it.data.copy(remainingTime = remainingTime))
                        }
                    }
                }
            }
    }

    override fun pause() {
        timerJob?.cancel()
        _timerState.value = TimerState.Paused(_timerState.value.data)
    }

    override fun resume() {
        if (_timerState.value is TimerState.Paused) {
            start()
        }
    }

    override fun reset() {
        timerJob?.cancel()
        _timerState.value =
            TimerState.Idle(
                _timerState.value.data.copy(
                    remainingTime = _timerState.value.data.totalTime,
                ),
            )
    }
}

TimerController Test Code

각 함수에 대한 테스트 코드.
advandeTimeBy를 사용하고는 runCurrent를 호출해주어야 예정되어있는 코루틴 작업이 실행된다.

@OptIn(ExperimentalCoroutinesApi::class)
class DefaultTimerControllerTest {
    private lateinit var timerController: TimerController
    private lateinit var testScope: TestScope
    private lateinit var dispatchersProvider: DispatchersProvider

    @Before
    fun setup() {
        dispatchersProvider = TestDispatcherProvider()
        testScope = TestScope(dispatchersProvider.default)

        timerController = DefaultTimerController(testScope, dispatchersProvider)
    }

    @After
    fun tearDown() {
        testScope.cancel()
    }

    @Test
    fun `state initialized correctly`() =
        testScope.runTest {
            timerController.setTimer(5000L)
            val state = timerController.timerState.value
            assertTrue(state is TimerState.Idle)
            assertEquals(5000L, state.data.remainingTime)
            assertEquals(5000L, state.data.totalTime)
        }

    @Test
    fun `timer starts correctly`() =
        testScope.runTest {
            timerController.setTimer(3000L)
            timerController.start()
            advanceTimeBy(2000L)
            runCurrent()

            val state = timerController.timerState.value
            assertTrue(state is TimerState.Running)
            assertEquals(1000L, state.data.remainingTime)
        }

    @Test
    fun `timer pauses correctly`() =
        testScope.runTest {
            timerController.setTimer(3000L)
            timerController.start()
            advanceTimeBy(2000L)
            runCurrent()
            timerController.pause()
            val pausedState = timerController.timerState.value
            assertTrue(pausedState is TimerState.Paused)
            val timeLeft = pausedState.data.remainingTime

            advanceTimeBy(2000L)
            runCurrent()
            assertEquals(timeLeft, timerController.timerState.value.data.remainingTime)
        }

    @Test
    fun `remaining timer is updated correctly`() =
        testScope.runTest {
            timerController.setTimer(3000L)
            timerController.start()
            advanceTimeBy(2000L)
            runCurrent()
            timerController.pause()
            val pausedTime = timerController.timerState.value.data.remainingTime
            timerController.resume()
            advanceTimeBy(1000L)
            runCurrent()

            val state = timerController.timerState.value
            assertTrue(state is TimerState.Finished)
            assertEquals((pausedTime - 1000L).coerceAtLeast(0L), state.data.remainingTime)
        }

    @Test
    fun `timer is reset correctly`() =
        testScope.runTest {
            timerController.setTimer(5000L)
            timerController.start()
            advanceTimeBy(2000L)
            runCurrent()
            timerController.reset()

            val state = timerController.timerState.value
            assertTrue(state is TimerState.Idle)
            assertEquals(5000L, state.data.remainingTime)
        }

    @Test
    fun `timer finishes correctly`() =
        testScope.runTest {
            timerController.setTimer(2000L)
            timerController.start()
            advanceTimeBy(2000L)
            runCurrent()

            val state = timerController.timerState.value
            assertTrue(state is TimerState.Finished)
            assertEquals(0L, state.data.remainingTime)
        }
}

Test Result


통과된것을 확인할 수 있다.

profile
Tireless And Restless Debugging In Source : TARDIS

0개의 댓글