NetworkBoundResource con corotinas de Kotlin

8

¿Tiene alguna idea de cómo implementar un patrón de repositorio con las rutinas NetworkBoundResource y Kotlin? Sé que podemos lanzar una corutina dentro de un GlobalScope, pero puede conducir a una fuga de rutina. Me gustaría pasar un viewModelScope como parámetro, pero es un poco complicado cuando se trata de implementación (porque mi repositorio no conoce un CoroutineScope de ningún ViewModel).

abstract class NetworkBoundResource<ResultType, RequestType>
@MainThread constructor(
    private val coroutineScope: CoroutineScope
) {

    private val result = MediatorLiveData<Resource<ResultType>>()

    init {
        result.value = Resource.loading(null)
        @Suppress("LeakingThis")
        val dbSource = loadFromDb()
        result.addSource(dbSource) { data ->
            result.removeSource(dbSource)
            if (shouldFetch(data)) {
                fetchFromNetwork(dbSource)
            } else {
                result.addSource(dbSource) { newData ->
                    setValue(Resource.success(newData))
                }
            }
        }
    }

    @MainThread
    private fun setValue(newValue: Resource<ResultType>) {
        if (result.value != newValue) {
            result.value = newValue
        }
    }

    private fun fetchFromNetwork(dbSource: LiveData<ResultType>) {
        val apiResponse = createCall()
        result.addSource(dbSource) { newData ->
            setValue(Resource.loading(newData))
        }
        result.addSource(apiResponse) { response ->
            result.removeSource(apiResponse)
            result.removeSource(dbSource)
            when (response) {
                is ApiSuccessResponse -> {
                    coroutineScope.launch(Dispatchers.IO) {
                        saveCallResult(processResponse(response))

                        withContext(Dispatchers.Main) {
                            result.addSource(loadFromDb()) { newData ->
                                setValue(Resource.success(newData))
                            }
                        }
                    }
                }

                is ApiEmptyResponse -> {
                    coroutineScope.launch(Dispatchers.Main) {
                        result.addSource(loadFromDb()) { newData ->
                            setValue(Resource.success(newData))
                        }
                    }
                }

                is ApiErrorResponse -> {
                    onFetchFailed()
                    result.addSource(dbSource) { newData ->
                        setValue(Resource.error(response.errorMessage, newData))
                    }
                }
            }
        }
    }
}
Kamil Szustak
fuente
2
En mi humilde opinión, el repositorio debe exponer suspendfunciones, o retorno Channel/ Flowobjetos, dependiendo de la naturaleza de la API. Las corutinas reales se configuran en el modelo de vista. LiveDataes introducido por el modelo de vista, no por el repositorio.
CommonsWare
@CommonsWare ¿Entonces propone reescribir NetworkBoundResource para devolver datos reales (o Recurso <T>), sin usar LiveData en él y en el repositorio?
Kamil Szustak el
Eres el que quiere usar NetworkBoundResource. Mis comentarios son más generales: en mi humilde opinión, una implementación de repositorio de Kotlin debería exponer API relacionadas con las rutinas.
CommonsWare
Me gustaría agradecerles a todos ustedes por ayudarme con esta pregunta y varias respuestas. Y gracias a @CommonsWare, cuya pista me ayudó a mejorar mi código (nuevamente)
Valerio
1
Lo diría más como una preferencia personal. LiveDatacarece del poder de las rutinas RxJava o Kotlin. LiveDataes muy bueno para las comunicaciones de "última milla" a la actividad o fragmento, y fue diseñado con eso en mente. Y para aplicaciones pequeñas, si desea omitir repositorios y simplemente ViewModelhablar directamente con un RoomDatabase, LiveDataestá bien.
CommonsWare

Respuestas:

7

La respuesta @ N1hk funciona bien, esta es solo una implementación diferente que no utiliza el flatMapConcatoperador (está marcada como FlowPreviewen este momento)

@FlowPreview
@ExperimentalCoroutinesApi
abstract class NetworkBoundResource<ResultType, RequestType> {

    fun asFlow() = flow {
        emit(Resource.loading(null))

        val dbValue = loadFromDb().first()
        if (shouldFetch(dbValue)) {
            emit(Resource.loading(dbValue))
            when (val apiResponse = fetchFromNetwork()) {
                is ApiSuccessResponse -> {
                    saveNetworkResult(processResponse(apiResponse))
                    emitAll(loadFromDb().map { Resource.success(it) })
                }
                is ApiErrorResponse -> {
                    onFetchFailed()
                    emitAll(loadFromDb().map { Resource.error(apiResponse.errorMessage, it) })
                }
            }
        } else {
            emitAll(loadFromDb().map { Resource.success(it) })
        }
    }

    protected open fun onFetchFailed() {
        // Implement in sub-classes to handle errors
    }

    @WorkerThread
    protected open fun processResponse(response: ApiSuccessResponse<RequestType>) = response.body

    @WorkerThread
    protected abstract suspend fun saveNetworkResult(item: RequestType)

    @MainThread
    protected abstract fun shouldFetch(data: ResultType?): Boolean

    @MainThread
    protected abstract fun loadFromDb(): Flow<ResultType>

    @MainThread
    protected abstract suspend fun fetchFromNetwork(): ApiResponse<RequestType>
}
Juan Cruz Soler
fuente
1
¿No es mejor emitir Resource.error en el caso ApiErrorResponse?
Kamil Szustak
¿Qué tipo de devolución de servicio debería ser?
Mahmood Ali
@MahmoodAli suspende la diversión someData (@ Query / @ Path): ApiResponse <List <Postitems>> ... adminístralo según tus datos
USMAN osman
3

Actualización (2020-05-27):

Una forma que es más idiomática para el lenguaje de Kotlin que mis ejemplos anteriores, utiliza las API de Flow y los préstamos de la respuesta de Juan se pueden representar como una función independiente como la siguiente:

inline fun <ResultType, RequestType> networkBoundResource(
    crossinline query: () -> Flow<ResultType>,
    crossinline fetch: suspend () -> RequestType,
    crossinline saveFetchResult: suspend (RequestType) -> Unit,
    crossinline onFetchFailed: (Throwable) -> Unit = { Unit },
    crossinline shouldFetch: (ResultType) -> Boolean = { true }
) = flow<Resource<ResultType>> {
    emit(Resource.Loading(null))
    val data = query().first()

    val flow = if (shouldFetch(data)) {
        emit(Resource.Loading(data))

        try {
            saveFetchResult(fetch())
            query().map { Resource.Success(it) }
        } catch (throwable: Throwable) {
            onFetchFailed(throwable)
            query().map { Resource.Error(throwable, it) }
        }
    } else {
        query().map { Resource.Success(it) }
    }

    emitAll(flow)
}

El código anterior se puede llamar desde una clase, por ejemplo, un repositorio, así:

fun getItems(request: MyRequest): Flow<Resource<List<MyItem>>> {
    return networkBoundResource(
        query = { dao.queryAll() },
        fetch = { retrofitService.getItems(request) },
        saveFetchResult = { items -> dao.insert(items) }
    )
}

Respuesta original:

Así es como lo he estado haciendo usando el livedata-ktxartefacto; No es necesario pasar ningún CoroutineScope. La clase también usa solo un tipo en lugar de dos (por ejemplo, ResultType / RequestType) ya que siempre termino usando un adaptador en otro lugar para mapearlos.

import androidx.lifecycle.LiveData
import androidx.lifecycle.liveData
import androidx.lifecycle.map
import nihk.core.Resource

// Adapted from: https://developer.android.com/topic/libraries/architecture/coroutines
abstract class NetworkBoundResource<T> {

    fun asLiveData() = liveData<Resource<T>> {
        emit(Resource.Loading(null))

        if (shouldFetch(query())) {
            val disposable = emitSource(queryObservable().map { Resource.Loading(it) })

            try {
                val fetchedData = fetch()
                // Stop the previous emission to avoid dispatching the saveCallResult as `Resource.Loading`.
                disposable.dispose()
                saveFetchResult(fetchedData)
                // Re-establish the emission as `Resource.Success`.
                emitSource(queryObservable().map { Resource.Success(it) })
            } catch (e: Exception) {
                onFetchFailed(e)
                emitSource(queryObservable().map { Resource.Error(e, it) })
            }
        } else {
            emitSource(queryObservable().map { Resource.Success(it) })
        }
    }

    abstract suspend fun query(): T
    abstract fun queryObservable(): LiveData<T>
    abstract suspend fun fetch(): T
    abstract suspend fun saveFetchResult(data: T)
    open fun onFetchFailed(exception: Exception) = Unit
    open fun shouldFetch(data: T) = true
}

Como dijo @CommonsWare en los comentarios, sin embargo, sería mejor exponer a Flow<T>. Esto es lo que he intentado hacer para hacer eso. Tenga en cuenta que no he usado este código en producción, así que tenga cuidado con el comprador.

import kotlinx.coroutines.flow.*
import nihk.core.Resource

abstract class NetworkBoundResource<T> {

    fun asFlow(): Flow<Resource<T>> = flow {
        val flow = query()
            .onStart { emit(Resource.Loading<T>(null)) }
            .flatMapConcat { data ->
                if (shouldFetch(data)) {
                    emit(Resource.Loading(data))

                    try {
                        saveFetchResult(fetch())
                        query().map { Resource.Success(it) }
                    } catch (throwable: Throwable) {
                        onFetchFailed(throwable)
                        query().map { Resource.Error(throwable, it) }
                    }
                } else {
                    query().map { Resource.Success(it) }
                }
            }

        emitAll(flow)
    }

    abstract fun query(): Flow<T>
    abstract suspend fun fetch(): T
    abstract suspend fun saveFetchResult(data: T)
    open fun onFetchFailed(throwable: Throwable) = Unit
    open fun shouldFetch(data: T) = true
}
N1hk
fuente
El Flowcódigo hará la solicitud de red nuevamente cuando los datos en la base de datos cambien, publiqué una nueva respuesta que muestra cómo manejarla
Juan Cruz Soler,
En mis pruebas puse un punto de interrupción dentro del flatMapConcatbloque y también dentro del query().map { Resource.Success(it) }bloque, luego inserté un elemento en la base de datos. Solo el último punto de quiebre fue alcanzado. En otras palabras, la solicitud de red no se vuelve a realizar cuando cambian los datos en la base de datos.
N1hk
Si coloca un punto de interrupción aquí if (shouldFetch(data)), verá que se llama dos veces. La primera vez que obtiene los resultados de la base de datos y la segunda cuando saveFetchResult(fetch())se llama
Juan Cruz Soler
Y si lo piensas, eso es lo que quieres cuando usas a Flow. Está guardando algo en la base de datos y desea que Room le informe sobre ese cambio y flatMapConcatvuelva a llamar a su código. No estabas Flow<T>usando Tay si no querías ese comportamiento
Juan Cruz Soler
3
Tienes razón, entendí mal el código. El flatMapConcatdevolverá un nuevo flujo para ser observado, por lo que ya no se llamará al flujo inicial. Ambas respuestas se comportan de la misma manera, por lo que conservaré la mía como una forma diferente de implementarla. Perdón por la confusión, y gracias por tu explicación!
Juan Cruz Soler
0

Soy nuevo en Kotlin Coroutine. Acabo de encontrarme con este problema esta semana.

Creo que si sigues el patrón de repositorio como se menciona en la publicación anterior, mi opinión es libre de pasar un CoroutineScope a NetworkBoundResource . El CoroutineScope puede ser uno de los parámetros de la función en el Repositorio , que devuelve un LiveData, como:

suspend fun getData(scope: CoroutineScope): LiveDate<T>

Pase el viewmodelscope de alcance incorporado como CoroutineScope al llamar a getData () en su ViewModel, de modo que NetworkBoundResource funcionará dentro del viewmodelscope y estará vinculado con el ciclo de vida del Viewmodel. La rutina en NetworkBoundResource se cancelará cuando ViewModel esté muerto, lo que sería un beneficio.

Para usar el alcance de vista del modelo incorporado , no olvide agregar a continuación en su build.gradle.

implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.2.0-alpha01'
Freddie
fuente