뽀모도로용 타이머 클래스를 만들어보고자 한다.
카운트 다운 타이머로, 액티비티나 서비스 모두에 사용가능하도록 독립된 클래스로 설계하려고 했다.
타이머 상태를 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()
}
타이머의 현재 시간 값을 저장하는 데이터 클래스.
현재 남은 시간과, 타이머가 설정된 시간을 저장한다.
totalTime을 가지고 있음으로서, 리셋을 간편하게 할 수 있다.
data class TimerData(
val remainingTime: Long = 0,
val totalTime: Long = 0,
)
타이머 상태를 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으로 접근할 코드를 조금이나마 줄일 수 있다.
타이머 인터페이스의 구현체.
타이머 자체는 코루틴을 활용하고 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,
),
)
}
}
각 함수에 대한 테스트 코드.
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)
}
}

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