Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add coroutine support #21

Draft
wants to merge 1 commit into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 19 additions & 3 deletions build.gradle
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
buildscript {
ext.kotlin_version = '1.3.11'
ext.kotlin_version = '1.4.30'
repositories {
google()
jcenter()
Expand All @@ -8,7 +8,7 @@ buildscript {
}
}
dependencies {
classpath 'com.android.tools.build:gradle:3.3.0'
classpath 'com.android.tools.build:gradle:3.5.0'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"

classpath 'com.jfrog.bintray.gradle:gradle-bintray-plugin:1.8.4'
Expand Down Expand Up @@ -57,4 +57,20 @@ ext {
libModuleName = 'roxie'
libModuleDesc = 'Lightweight Android library for building reactive apps.'
libBintrayName = 'roxie'
}
}

tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).all {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8

kotlinOptions {
jvmTarget = '1.8'
apiVersion = '1.4'
languageVersion = '1.4'
}

// Enable experimental Kotlin APIs
freeCompilerArgs += [
'-Xopt-in=kotlin.RequiresOptIn'
]
}
3 changes: 2 additions & 1 deletion gradle/wrapper/gradle-wrapper.properties
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
#Sun Sep 01 16:05:40 EDT 2019
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-5.0-all.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-5.4.1-all.zip
3 changes: 3 additions & 0 deletions roxie/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ android {
dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"


api 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.4.3'
api "androidx.lifecycle:lifecycle-viewmodel-ktx:2.3.0"
// ViewModel and LiveData
compileOnly "androidx.lifecycle:lifecycle-extensions:$rootProject.ext.lifecycleVersion"

Expand Down
64 changes: 64 additions & 0 deletions roxie/src/main/kotlin/com/ww/roxie/BaseCoroutineViewModel.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/*
* Copyright (C) 2019. WW International, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.ww.roxie

import androidx.lifecycle.LiveData
import androidx.lifecycle.MediatorLiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.launch

/**
* Store which manages business data and state.
*/
abstract class BaseCoroutineViewModel<A : BaseAction, S : BaseState> : ViewModel() {
private val _actionsFlow = MutableSharedFlow<A>()
protected val actions : SharedFlow<A> = _actionsFlow

protected abstract val initialState: S

protected val state = MutableLiveData<S>()

private val tag by lazy { javaClass.simpleName }

/**
* Returns the current state. It is equal to the last value returned by the store's reducer.
*/
val observableState: LiveData<S> = MediatorLiveData<S>().apply {
addSource(state) { data ->
Roxie.log("$tag: Received state: $data")
setValue(data)
}
}

/**
* Dispatches an action. This is the only way to trigger a state change.
*/
fun dispatch(action: A) {
Roxie.log("$tag: Received action: $action")
viewModelScope.launch {
_actionsFlow.emit(action)
}
}

override fun onCleared() {
// TODO: Close flow somehow? Emit terminal event?
// _actionsFlow.cancel()
}
}
11 changes: 11 additions & 0 deletions sample/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,14 @@ dependencies {
testImplementation 'com.nhaarman:mockito-kotlin:1.6.0'
testImplementation 'androidx.arch.core:core-testing:2.0.0'
}

tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).all {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8

kotlinOptions {
jvmTarget = '1.8'
apiVersion = '1.4'
languageVersion = '1.4'
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ object NoteRepository {

fun loadAll(): List<Note> = notes.toList()

fun findById(id: Long): Note? = notes.firstOrNull { it.id == id }
suspend fun findById(id: Long): Note? = notes.firstOrNull { it.id == id }

fun delete(note: Note): Boolean = notes.remove(note)
suspend fun delete(note: Note): Boolean = notes.remove(note)
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,10 @@ import com.ww.roxiesample.data.NoteRepository
import io.reactivex.Completable

class DeleteNoteUseCase {
fun delete(note: Note): Completable =
when {
NoteRepository.delete(note) -> Completable.complete()
else -> Completable.error(RuntimeException("Unable to delete note $note"))
suspend fun delete(note: Note) {
val deleteSuccess = NoteRepository.delete(note)
if (!deleteSuccess) {
throw RuntimeException("Unable to delete note $note")
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,10 @@
package com.ww.roxiesample.domain

import com.ww.roxiesample.data.NoteRepository
import io.reactivex.Single

class GetNoteDetailUseCase {
fun findById(id: Long): Single<Note> {
return NoteRepository.findById(id)?.let { note ->
Single.just(note)
} ?: Single.error(IllegalArgumentException("Invalid note id passed in"))
suspend fun findById(id: Long): Note {
// TODO: Is throwing the right thing to do below?
return NoteRepository.findById(id) ?: throw IllegalArgumentException("Invalid note id passed in")
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,12 @@ package com.ww.roxiesample.domain

import com.ww.roxiesample.data.NoteRepository
import io.reactivex.Single
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext

class GetNoteListUseCase {
fun loadAll(): Single<List<Note>> = Single.just(NoteRepository.loadAll())
}

suspend fun loadAll(): List<Note> = withContext(Dispatchers.IO){
NoteRepository.loadAll()
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright (C) 2019Action.kt. WW International, Inc.
* Copyright (C) 2019. WW International, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
Expand All @@ -15,21 +15,22 @@
*/
package com.ww.roxiesample.presentation.notedetail

import com.ww.roxie.BaseViewModel
import androidx.lifecycle.viewModelScope
import com.ww.roxie.BaseCoroutineViewModel
import com.ww.roxie.Reducer
import com.ww.roxiesample.domain.DeleteNoteUseCase
import com.ww.roxiesample.domain.GetNoteDetailUseCase
import io.reactivex.Observable
import io.reactivex.rxkotlin.ofType
import io.reactivex.rxkotlin.plusAssign
import io.reactivex.schedulers.Schedulers
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import timber.log.Timber

class NoteDetailViewModel(
initialState: State?,
private val noteDetailUseCase: GetNoteDetailUseCase,
private val deleteNoteUseCase: DeleteNoteUseCase
) : BaseViewModel<Action, State>() {
) : BaseCoroutineViewModel<Action, State>() {

override val initialState = initialState ?: State(isIdle = true)

Expand Down Expand Up @@ -62,37 +63,40 @@ class NoteDetailViewModel(
}

init {
bindActions()
viewModelScope.launch {
bindActions()
}
}

private fun bindActions() {
val loadNoteChange = actions.ofType<Action.LoadNoteDetail>()
.switchMap { action ->
noteDetailUseCase.findById(action.noteId)
.subscribeOn(Schedulers.io())
.toObservable()
.map<Change> { Change.NoteDetail(it) }
.onErrorReturn { Change.NoteLoadError(it) }
.startWith(Change.Loading)
@OptIn(ExperimentalCoroutinesApi::class)
private suspend fun bindActions() {
val loadNoteChange = actions.filterIsInstance<Action.LoadNoteDetail>()
.mapLatest { action ->
Timber.v("Received action: $action, thread; ${Thread.currentThread().name}")
Change.NoteDetail(noteDetailUseCase.findById(action.noteId))
}
.flowOn(Dispatchers.IO)
.onStart<Change> { emit(Change.Loading) }
.catch { emit(Change.NoteLoadError(it)) }

val deleteNoteChange = actions.ofType<Action.DeleteNote>()
.switchMap { action ->
noteDetailUseCase.findById(action.noteId)
.subscribeOn(Schedulers.io())
.flatMapCompletable { deleteNoteUseCase.delete(it) }
.toSingleDefault<Change>(Change.NoteDeleted)
.onErrorReturn { Change.NoteDeleteError(it) }
.toObservable()
.startWith(Change.Loading)
val deleteNoteChange = actions.filterIsInstance<Action.DeleteNote>()
.mapLatest { action ->
Timber.v("Received action: $action, thread; ${Thread.currentThread().name}")
val findById = noteDetailUseCase.findById(action.noteId)
deleteNoteUseCase.delete(findById)
Change.NoteDeleted
}
.onStart<Change> { emit(Change.Loading) }
.catch { emit(Change.NoteDeleteError(it)) }
.flowOn(Dispatchers.IO)

val allChanges = Observable.merge(loadNoteChange, deleteNoteChange)
val allChanges = merge(loadNoteChange, deleteNoteChange)

disposables += allChanges
.scan(initialState, reducer)
allChanges.scan(initialState) { state, change -> reducer(state, change) }
.filter { !it.isIdle && !it.isLoading }
.distinctUntilChanged()
.subscribe(state::postValue, Timber::e)
.collect {
state.postValue(it)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,19 +15,30 @@
*/
package com.ww.roxiesample.presentation.notelist

import com.ww.roxie.BaseViewModel
import androidx.lifecycle.viewModelScope
import com.ww.roxie.BaseCoroutineViewModel
import com.ww.roxie.Reducer
import com.ww.roxiesample.domain.GetNoteListUseCase
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.rxkotlin.ofType
import io.reactivex.rxkotlin.plusAssign
import io.reactivex.schedulers.Schedulers
import timber.log.Timber
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.filterNot
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.collect

import kotlinx.coroutines.flow.scan
import kotlinx.coroutines.launch

@OptIn(ExperimentalCoroutinesApi::class)
class NoteListViewModel(
initialState: State?,
private val loadNoteListUseCase: GetNoteListUseCase
) : BaseViewModel<Action, State>() {
) : BaseCoroutineViewModel<Action, State>() {

override val initialState = initialState ?: State(isIdle = true)

Expand All @@ -51,29 +62,24 @@ class NoteListViewModel(
}

init {
bindActions()
viewModelScope.launch {
bindActions()
}
}

private fun bindActions() {
val loadNotesChange = actions.ofType<Action.LoadNotes>()
.switchMap {
loadNoteListUseCase.loadAll()
.subscribeOn(Schedulers.io())
.toObservable()
.map<Change> { Change.Notes(it) }
.defaultIfEmpty(Change.Notes(emptyList()))
.onErrorReturn { Change.Error(it) }
.startWith(Change.Loading)
}
@ExperimentalCoroutinesApi
private suspend fun bindActions() {
val loadNotesChange: Flow<Change> = actions.filterIsInstance<Action.LoadNotes>()
.mapLatest { Change.Notes(loadNoteListUseCase.loadAll().ifEmpty { emptyList() }) }
.catch<Change> { emit(Change.Error(it)) } // TODO: Do we need to emit?
.flowOn(Dispatchers.IO)
.onStart { emit(Change.Loading) }

// to handle multiple Changes, use Observable.merge to merge them into a single stream:
// val allChanges = Observable.merge(loadNotesChange, ...)

disposables += loadNotesChange
.scan(initialState, reducer)
.filter { !it.isIdle }
loadNotesChange.scan(initialState) { state, change -> reducer(state, change) }
.filterNot { it.isIdle }
.distinctUntilChanged()
.observeOn(AndroidSchedulers.mainThread())
.subscribe(state::setValue, Timber::e)
.collect {
state.value = it
}
}
}