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
GitHub - tkdgusl94/blog-source: https://leveloper.tistory.com/ 에서 제공하는 예제 source
https://leveloper.tistory.com/ 에서 제공하는 예제 source. Contribute to tkdgusl94/blog-source development by creating an account on GitHub.
github.com
참고
Simplify Android Viewmodels by using these kotlin extenstions (part 1)
Every day I’am surprised with kotlin (extenstions) power. I’am going to share a set of extenstions that it’s currently used in our…
kapta.medium.com
'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 |