이 포스트는 안드로이드 공식 Codelab을 기반으로 작성되었습니다. 링크
이 포스트는 이전 포스트에서 이어지는 포스트입니다.
ViewModel
, Repository
, Room
, Retrofit
에 코루틴을 추가해보자.
앞서 프로젝트의 각 구성요소들이 어떤 역할을 맡고 있는지 알아보자.
1. MainDatabase
: Room
을 이용하여 Title
데이터 값을 저장하고 가져온다.
2. MainNetwork
: 새로운 title 값을 가져오는 네트워크 API를 포함한다. Retrofit
으로 title을 가져오며 Retrofit
은 랜덤하게 에러나 mock 데이터를 반환하도록 설계되어있다.
3. TitleRepository
: 네트워크와 데이터베이스의 데이터를 조합함으로써 title 값을 가져오거나 새로고침하도록 하는 1개의 API를 포함한다.
4. MainViewModel
: 화면의 상태를 표시하고 각종 이벤트를 핸들링한다. 이벤트가 들어오면 repository에게 taps 값을 변경하고 title을 새로고침하도록 요청한다.
네트워크 요청이 UI 이벤트로부터 발생하고 그곳이 코루틴이 시작되는 지점이기 때문에 ViewModel
에서 코루틴을 시작하는 것이 가장 자연스럽다.
MainViewModel.kt
/**
* Update title text via this LiveData
*/
val title = repository.title
// ... other code ...
/**
* Refresh the title, showing a loading spinner while it refreshes and errors via snackbar.
*/
fun refreshTitle() {
// TODO: Convert refreshTitle to use coroutines
_spinner.value = true
repository.refreshTitleWithCallbacks(object: TitleRefreshCallback {
override fun onCompleted() {
_spinner.postValue(false)
}
override fun onError(cause: Throwable) {
_snackBar.postValue(cause.message)
_spinner.postValue(false)
}
})
}
기존 코드는 콜백 형식으로 작성되어있다.
위 코드를 코루틴 방식으로 수정하기 위해선 먼저 refreshTitleWithCallbacks
라는 콜백 기반 함수를 코루틴 기반으로 작동하는 refreshTitle
함수로 수정할 필요가 있다.
TitleRepository.kt
suspend fun refreshTitle() {
// TODO: Refresh from network and write to database
delay(500)
}
TitleRepository.kt
에 refreshTitle
이라는 suspend
함수를 추가한다.
MainViewModel.kt
/**
* Refresh the title, showing a loading spinner while it refreshes and errors via snackbar.
*/
fun refreshTitle() {
viewModelScope.launch {
try {
_spinner.value = true
repository.refreshTitle()
} catch (error: TitleRefreshError) {
_snackBar.value = error.message
} finally {
_spinner.value = false
}
}
}
MainViewModel
의 refreshTitle
함수를 위와 같이 수정한다. 역시나 viewModelScope
에서 코루틴을 시작한다.
기존의 refreshTitleWithCallbacks()
함수를 살펴보자.
// TitleRepository.kt
fun refreshTitleWithCallbacks(titleRefreshCallback: TitleRefreshCallback) {
// This request will be run on a background thread by retrofit
BACKGROUND.submit {
try {
// Make network request using a blocking call
val result = network.fetchNextTitle().execute()
if (result.isSuccessful) {
// Save it to database
titleDao.insertTitle(Title(result.body()!!))
// Inform the caller the refresh is completed
titleRefreshCallback.onCompleted()
} else {
// If it's not successful, inform the callback of the error
titleRefreshCallback.onError(
TitleRefreshError("Unable to refresh title", null))
}
} catch (cause: Throwable) {
// If anything throws an exception, inform the caller
titleRefreshCallback.onError(
TitleRefreshError("Unable to refresh title", cause))
}
}
}
위 함수는 BACKGROUND
로 메인 스레드가 아닌 백그라운드 스레드를 생성해 fetch 작업을 수행함으로써 main-safe
하다.
코루틴으로 위 함수를 작성할때도 마찬가지로 main-safe
하게 작성해야한다.
사용할 수 있는 방법 중 하나는 코루틴 도중 withContext
를 사용하여 dispatcher
를 전환하는 것이다. 시간이 오래 걸리는 작업을 수행할 때 Dispatchers.Main
이 아닌 Dispatchers.IO
등 백그라운드 스레드로 전환함으로써 main-safe
를 지키는 것이다.
// TitleRepository.kt
suspend fun refreshTitle() {
// interact with *blocking* network and IO calls from a coroutine
withContext(Dispatchers.IO) {
val result = try {
// Make network request using a blocking call
network.fetchNextTitle().execute()
} catch (cause: Throwable) {
// If the network throws an exception, inform the caller
throw TitleRefreshError("Unable to refresh title", cause)
}
if (result.isSuccessful) {
// Save it to database
titleDao.insertTitle(Title(result.body()!!))
} else {
// If it's not successful, inform the callback of the error
throw TitleRefreshError("Unable to refresh title", null)
}
}
}
위 함수는blocking
되는 호출을 포함하고있다. execute()
라던가 insertTitle(...)
처럼 스레드를 멈추게할 수 있는 함수가 있지만 IO Dispatcher로 스레드를 전환했기 때문에 메인 스레드는 suspend
된 상태로 따로 동작하게된다.
다음은 Room
데이터베이스에 코루틴을 적용할 차례다. 먼저 MainDatabase.kt
을 열어 insertTitle
함수에 suspend
키워드를 추가한다.
// add the suspend modifier to the existing insertTitle
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertTitle(title: Title)
이제 Room
의 쿼리는 main-safe
가 되었다. 이제 insertTitle
쿼리는 코루틴 내부에서만 호출할 수 있게 되었다.
그 다음 Retrofit
에 코루틴을 적용한다.
MainNetwork.kt
// add suspend modifier to the existing fetchNextTitle
// change return type from Call<String> to String
interface MainNetwork {
@GET("next_title.json")
suspend fun fetchNextTitle(): String
}
Room
에서와 마찬가지로 suspend
키워드를 추가하고 return type에 Call
wrapper를 없애주면 된다.
Room
과 Retrofit
에 코루틴을 적용하였으므로 이제 TitleRepository.kt
의 refreshTitle
도 수정해줘야한다.
suspend fun refreshTitle() {
try {
// Make network request using a blocking call
val result = network.fetchNextTitle()
titleDao.insertTitle(Title(result))
} catch (cause: Throwable) {
// If anything throws an exception, inform the caller
throw TitleRefreshError("Unable to refresh title", cause)
}
}
코드가 훨씬 간략해졌음을 확인할 수 있다.
MainViewModel
의 refreshTitle
함수를 리팩토링해보자.
먼저 기존 refreshTitle
코드를 살펴보자.
// MainViewModel.kt
fun refreshTitle() {
viewModelScope.launch {
try {
_spinner.value = true
// this is the only part that changes between sources
repository.refreshTitle()
} catch (error: TitleRefreshError) {
_snackBar.value = error.message
} finally {
_spinner.value = false
}
}
}
위 코드에서 실질적으로 데이터를 변경시키는 부분은 repository.refreshTitle()
단 한 줄이다.
따라서 위 코드를 필요한 부분만 보이게 하고 나머지 부분은 일반화(generalize) 시키는 작업을 진행할 수 있다.
MainViewModel.kt
// Add this code to MainViewModel.kt
private fun launchDataLoad(block: suspend () -> Unit): Job {
return viewModelScope.launch {
try {
_spinner.value = true
block()
} catch (error: TitleRefreshError) {
_snackBar.value = error.message
} finally {
_spinner.value = false
}
}
}
fun refreshTitle() {
launchDataLoad {
repository.refreshTitle()
}
}
WorkManager
에 대해서 아직 제대로 다뤄본 적이 없기 때문에 간략하게 살펴보자.
WorkManager
란 지속적인 백그라운드 작업 처리에 활용할 수 있는 API이다.
override suspend fun doWork(): Result {
val database = getDatabase(applicationContext)
val repository = TitleRepository(network, database.titleDao)
return try {
repository.refreshTitle()
Result.success()
} catch (error: TitleRefreshError) {
Result.failure()
}
}