ViewModel에서 코루틴을 사용할 때는 androidx-lifecycle에서 제공하는 viewModelScope를 많이 사용합니다. viewModelScope는 ViewModel의 extension property로 ViewModel이 destroy 될 때 자식 코루틴들을 자동으로 취소하는 기능을 제공합니다. 이번 포스팅에서는 viewModelScope를 보다 간단하게 사용할 수 있는 방법을 알아보겠습니다.
확장 함수 사용하여 개선하기
ViewModel에서 viewModelScope을 사용해 코루틴을 실행할 때는 일반적으로 아래와 같은 방식으로 사용합니다.
class MainViewModel : ViewModel() {
init {
viewModelScope.launch {
// ...
}
viewModelScope.launch(Dispatchers.IO) {
// ...
}
}
}
위의 코드는 큰 문제는 없지만, 다음의 확장 함수를 사용하면 viewModelScope의 반복을 방지할 수 있으며 훨씬 간단하고 읽기 쉬운 코드를 작성할 수 있습니다.
inline fun ViewModel.onMain(
crossinline body: suspend CoroutineScope.() -> Unit
) = viewModelScope.launch {
body(this)
}
inline fun ViewModel.onIO(
crossinline body: suspend CoroutineScope.() -> Unit
) = viewModelScope.launch(Dispatchers.IO) {
body(this)
}
inline fun ViewModel.onDefault(
crossinline body: suspend CoroutineScope.() -> Unit
) = viewModelScope.launch(Dispatchers.Default) {
body(this)
}
사용법은 다음과 같습니다. 이전 방식과 비교하여 훨씬 간단해졌음을 알 수 있습니다.
class MainViewModel : ViewModel() {
private fun doDefaultOperation() = onDefault {
// ...
}
private lateinit var job: Job
private fun cancelPreviousJobAndStartNewOne() {
if (::job.isInitialized) {
job.cancel()
}
job = onIO {
// ...
}
}
}
Testable 하게 개선하기
위의 설명에서는 Dispatchers를 하드 코딩하여 바로 지정해주는 방식을 사용했었습니다. 하지만 이와 같은 방식은 좋은 방식이 아닙니다. 안드로이드 구글 공식 사이트의 코루틴 권장사항에 따르면 새로운 코루틴을 만들 때 Dispatcher를 주입시켜주는 것이 좋습니다.
Dispatcher를 하드 코딩 방식으로 지정해주면 테스트 코드를 작성할 수 없습니다. (참고) 따라서 Dispatcher를 외부에서 주입하는 방식으로 코드를 수정해보겠습니다.
우선 기본 Dispatcher를 가지고 있는 DispatcherProvider 인터페이스를 정의해줍니다. 이 인터페이스의 구현체는 총 2가지로 하나는 프로덕션 코드용이고, 하나는 테스트 코드용입니다.
interface DispatcherProvider {
val main: CoroutineDispatcher
val io: CoroutineDispatcher
val default: CoroutineDispatcher
}
@Module
@InstallIn(ViewModelComponent::class)
interface DispatcherModule {
@Binds
fun bindDispatcherProvider(provider: DispatcherProviderImpl): DispatcherProvider
}
@Singleton
class DispatcherProviderImpl @Inject constructor() : DispatcherProvider {
override val main: CoroutineDispatcher
get() = Dispatchers.Main
override val io: CoroutineDispatcher
get() = Dispatchers.IO
override val default: CoroutineDispatcher
get() = Dispatchers.Default
}
그 후, DispatcherProvider를 외부에서 주입받아 BaseViewModel를 구현해줍니다. 기존의 만들어뒀던 확장 함수들도 DispatcherProvider를 사용하여 Dispatcher를 설정해줍니다.
open class BaseViewModel(dispatcherProvider: DispatcherProvider) : ViewModel(),
DispatcherProvider by dispatcherProvider
inline fun BaseViewModel.onMain(
crossinline body: suspend CoroutineScope.() -> Unit
) = viewModelScope.launch {
body(this)
}
inline fun BaseViewModel.onIO(
crossinline body: suspend CoroutineScope.() -> Unit
) = viewModelScope.launch(io) {
body(this)
}
inline fun BaseViewModel.onDefault(
crossinline body: suspend CoroutineScope.() -> Unit
) = viewModelScope.launch(default) {
body(this)
}
사용법은 다음과 같습니다.
@HiltViewModel
class MainViewModel @Inject constructor(
dispatcherProvider: DispatcherProvider
) : BaseViewModel(dispatcherProvider) {
var isSaved = false
fun saveDataOnIO() = onIO {
delay(5000)
isSaved = true
}
}
테스트 코드 작성
이제 ViewModel을 테스트할 수 있는 모든 준비가 끝났습니다. 간단한 테스트 코드를 하나 작성해보겠습니다.
테스트 코드에서만 사용할 TestDispatcherProvider를 구현해줍니다. 생성자로 주입받는 testDispatcher는 kotlinx.coroutines.test에서 제공하는 TestCoroutineScope를 사용합니다.
class TestDispatcherProvider(
private val testDispatcher: CoroutineDispatcher
) : DispatcherProvider {
override val main: CoroutineDispatcher
get() = testDispatcher
override val io: CoroutineDispatcher
get() = testDispatcher
override val default: CoroutineDispatcher
get() = testDispatcher
}
모든 테스트 코드에서 공통적으로 사용할 Rule을 만들어줍니다.
@ExperimentalCoroutinesApi
class MainCoroutineRule(val testCoroutineDispatcher: TestCoroutineDispatcher = TestCoroutineDispatcher()) :
TestRule, TestCoroutineScope by TestCoroutineScope() {
private val testCoroutineScope = TestCoroutineScope(testCoroutineDispatcher)
override fun apply(base: Statement, description: Description?) = object : Statement() {
@Throws(Throwable::class)
override fun evaluate() {
Dispatchers.setMain(testCoroutineDispatcher)
base.evaluate()
Dispatchers.resetMain()
testCoroutineScope.cleanupTestCoroutines()
}
}
fun runBlockingTest(block: suspend TestCoroutineScope.() -> Unit) =
testCoroutineScope.runBlockingTest { block() }
}
@ExperimentalCoroutinesApi
class MainViewModelTest : TestCase() {
private var mainCoroutineRule = MainCoroutineRule()
private val mainViewModel = MainViewModel(
TestDispatcherProvider(mainCoroutineRule.testCoroutineDispatcher)
)
fun test01_save_data() {
mainCoroutineRule.runBlockingTest {
mainViewModel.saveDataOnIO()
advanceUntilIdle()
assertTrue(mainViewModel.isSaved)
}
mainCoroutineRule.testCoroutineDispatcher.cleanupTestCoroutines()
}
}
예제 소스
https://github.com/tkdgusl94/blog-source/tree/master/ViewModelExtensions
참고
'Android' 카테고리의 다른 글
[Android] Gson을 이용한 Room에 다양한 타입의 데이터 저장하기 (0) | 2021.09.24 |
---|---|
[Android] 스켈레톤 로딩 화면 구현하기 - Facebook shimmer library (0) | 2021.09.13 |
[Android] API key값 local.properties에 안전하게 보관하기 (2) | 2021.08.25 |
[Android] BottomNavigationView에서 Fragment 전환 (3) | 2021.08.09 |
[Android] Kotlin Coroutine 기본 개념 익히기 (0) | 2021.07.29 |