Skip to content

Instantly share code, notes, and snippets.

@ildar2
Created April 12, 2022 12:11
Show Gist options
  • Save ildar2/7db73ed0b721d4cc441f3e5e2d8a526b to your computer and use it in GitHub Desktop.
Save ildar2/7db73ed0b721d4cc441f3e5e2d8a526b to your computer and use it in GitHub Desktop.
import androidx.lifecycle.ViewModel
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.koin.core.qualifier.named
import kotlin.coroutines.CoroutineContext
/**
* Base ViewModel class for MVVM
*
* uses kotlin coroutines
*
* has:
* - [contextProvider] - provides IO or Main context for coroutines
* - [coroutineJob] - parent Job for all running coroutines, see [stop]
* - [scope] - coroutine scope
* - [uiCaller] - helper class to make IO-operations (network, data bases)
*
* MVVM Views (fragments, compose screens) should observe:
* - [UiCaller.errorSharedFlow] to show errors
* - [UiCaller.statusStateFlow] to track loading status (can be customized)
*/
abstract class BaseViewModel(
protected val contextProvider: ContextProvider = ContextProvider(),
private val coroutineJob: Job = SupervisorJob(),
protected val scope: CoroutineScope = CoroutineScope(coroutineJob + contextProvider.IO),
protected val uiCaller: UiCallerImpl = UiCallerImpl(scope, contextProvider)
) : ViewModel(), UiCaller by uiCaller {
open fun stop() {
coroutineJob.cancelChildren()
}
override fun onCleared() {
super.onCleared()
coroutineJob.cancel()
}
protected infix fun <T> FlowCollector<T>.emit(value: T) {
scope.launch { emit(value) }
}
}
/**
* Interface to communicate with MVVM views
*/
interface UiCaller {
val statusStateFlow: StateFlow<Status>
val errorSharedFlow: SharedFlow<String>
}
/**
* Helper class, incapsulating request handling logic
* can be distributed to ViewModel extentions (composition)
*
* - makes IO-requests:
* - [makeRequest] with helper fun [unwrap]
* - [makeRequestUnwrapped]
* - [makeRequestFor]
* - sends errors to [errorSharedFlow]
* @see setError
* - shows loading state via [statusStateFlow]
* even for multiple requests
* @see set
*/
class UiCallerImpl(
val scope: CoroutineScope,
private val contextProvider: ContextProvider = ContextProvider(),
) : UiCaller {
private val _statusStateFlow: MutableStateFlow<Status> = MutableStateFlow(Status.HIDE_LOADING)
override val statusStateFlow: StateFlow<Status> = _statusStateFlow
private val _errorSharedFlow: MutableSharedFlow<String> = MutableSharedFlow()
override val errorSharedFlow: SharedFlow<String> = _errorSharedFlow
/**
* Presentation layer handler for requests
* launches [Job] in [scope],
* sets loading state on [_statusStateFlow]
*
* [call] - suspend repository function
* [statusFlow] - can be customized with different stateFlows (or null)
* [resultBlock] - function to handle result (called in Main)
*
* returns [Job] for potential canceling
*/
fun <T> makeRequest(
call: suspend CoroutineScope.() -> T,
statusFlow: MutableStateFlow<Status>? = _statusStateFlow,
resultBlock: (suspend (T) -> Unit)?
): Job = scope.launch(contextProvider.Main) {
set(Status.SHOW_LOADING, statusFlow)
try {
val result = withContext(contextProvider.IO, call)
resultBlock?.invoke(result)
} catch (e: Throwable) {
if (e !is CancellationException) {
setError(e.message.orEmpty())
}
}
set(Status.HIDE_LOADING, statusFlow)
}
/**
* To keep track of multiple [makeRequest] calls
*/
private var requestCounter = 0
/**
* Setting loading state for [statusFlow]
* [_statusStateFlow] by default
* can be customized with different statusFlow or `null`
*/
private fun set(status: Status, statusFlow: MutableStateFlow<Status>?) {
statusFlow ?: return
if (statusFlow === _statusStateFlow) {
when (status) {
Status.SHOW_LOADING -> {
requestCounter++
}
Status.HIDE_LOADING -> {
requestCounter--
if (requestCounter > 0) return
requestCounter = 0
}
}
}
statusFlow.value = status
}
fun setError(error: String) {
scope.launch {
_errorSharedFlow.emit(error)
}
}
fun <T> makeRequestUnwrapped(
call: suspend CoroutineScope.() -> RequestResult<T>,
statusFlow: MutableStateFlow<Status>? = _statusStateFlow,
resultBlock: (suspend (T) -> Unit)?
): Job = scope.launch(contextProvider.Main) {
set(Status.SHOW_LOADING, statusFlow)
try {
when (val result = withContext(contextProvider.IO, call)) {
is RequestResult.Error -> setError(result.error)
is RequestResult.Success -> resultBlock?.invoke(result.result)
}
} catch (e: Throwable) {
if (e !is CancellationException) {
setError(e.message.orEmpty())
}
}
set(Status.HIDE_LOADING, statusFlow)
}
/**
* Helper function to unwrap [RequestResult]
*/
fun <T> unwrap(
result: RequestResult<T>,
errorBlock: ((String) -> Unit)?,
successBlock: (T) -> Unit
) = when (result) {
is RequestResult.Success -> result.result?.let { successBlock(it) }
is RequestResult.Error -> errorBlock?.invoke(result.error)
}
fun <T> makeRequestFor(
statusStateFlow: MutableStateFlow<T>,
statusFlow: MutableStateFlow<Status>? = _statusStateFlow,
call: suspend CoroutineScope.() -> RequestResult<T>
) = makeRequest(call, statusFlow) {
when (it) {
is RequestResult.Success<T> -> statusStateFlow.value = it.result
is RequestResult.Error -> setError(it.error)
}
}
}
/**
* Used in [BaseViewModel] to make coroutine scope
* should be mocked in tests with [Dispatchers.Unconfined]
*/
class ContextProvider : KoinComponent {
val Main: CoroutineContext by inject(named("main"))//Dispatchers.Main
val IO: CoroutineContext by inject(named("io"))//Dispatchers.IO
}
/**
* Loading status
* @see UiCaller
*/
enum class Status {
SHOW_LOADING,
HIDE_LOADING,
}
/**
* Result wrapper for presentation layer
* should be returned by repositories using [CoroutineCaller]
*/
sealed class RequestResult<out T : Any?> {
data class Success<out T : Any?>(val result: T) : RequestResult<T>()
data class Error(val error: String, val code: Int = 0) : RequestResult<Nothing>()
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment