-
Notifications
You must be signed in to change notification settings - Fork 198
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
ViewModel refactor - common ViewModel by composition #231
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -3,70 +3,20 @@ package co.touchlab.kampkit.android | |
import androidx.lifecycle.ViewModel | ||
import androidx.lifecycle.viewModelScope | ||
import co.touchlab.kampkit.db.Breed | ||
import co.touchlab.kampkit.injectLogger | ||
import co.touchlab.kampkit.models.BreedModel | ||
import co.touchlab.kampkit.models.DataState | ||
import co.touchlab.kampkit.models.ItemDataSummary | ||
import co.touchlab.kampkit.models.BreedCommonViewModel | ||
import co.touchlab.kampkit.models.BreedRepository | ||
import co.touchlab.kermit.Logger | ||
import kotlinx.coroutines.FlowPreview | ||
import kotlinx.coroutines.flow.MutableStateFlow | ||
import kotlinx.coroutines.flow.StateFlow | ||
import kotlinx.coroutines.flow.collect | ||
import kotlinx.coroutines.flow.flattenMerge | ||
import kotlinx.coroutines.flow.flowOf | ||
import kotlinx.coroutines.launch | ||
import org.koin.core.component.KoinComponent | ||
|
||
class BreedViewModel : ViewModel(), KoinComponent { | ||
class BreedViewModel( | ||
breedRepository: BreedRepository, | ||
log: Logger | ||
) : ViewModel() { | ||
|
||
private val log: Logger by injectLogger("BreedViewModel") | ||
private val scope = viewModelScope | ||
private val breedModel: BreedModel = BreedModel() | ||
private val _breedStateFlow: MutableStateFlow<DataState<ItemDataSummary>> = MutableStateFlow( | ||
DataState(loading = true) | ||
) | ||
private val commonViewModel = BreedCommonViewModel(breedRepository, log, viewModelScope) | ||
|
||
val breedStateFlow: StateFlow<DataState<ItemDataSummary>> = _breedStateFlow | ||
val breeds = commonViewModel.breeds | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Maybe |
||
|
||
init { | ||
observeBreeds() | ||
} | ||
fun refreshBreeds() = commonViewModel.refreshBreeds() | ||
|
||
@OptIn(FlowPreview::class) | ||
private fun observeBreeds() { | ||
scope.launch { | ||
log.v { "getBreeds: Collecting Things" } | ||
flowOf( | ||
breedModel.refreshBreedsIfStale(true), | ||
breedModel.getBreedsFromCache() | ||
).flattenMerge().collect { dataState -> | ||
if (dataState.loading) { | ||
val temp = _breedStateFlow.value.copy(loading = true) | ||
_breedStateFlow.value = temp | ||
} else { | ||
_breedStateFlow.value = dataState | ||
} | ||
} | ||
} | ||
} | ||
|
||
fun refreshBreeds(forced: Boolean = false) { | ||
scope.launch { | ||
log.v { "refreshBreeds" } | ||
breedModel.refreshBreedsIfStale(forced).collect { dataState -> | ||
if (dataState.loading) { | ||
val temp = _breedStateFlow.value.copy(loading = true) | ||
_breedStateFlow.value = temp | ||
} else { | ||
_breedStateFlow.value = dataState | ||
} | ||
} | ||
} | ||
} | ||
|
||
fun updateBreedFavorite(breed: Breed) { | ||
scope.launch { | ||
breedModel.updateBreedFavorite(breed) | ||
} | ||
} | ||
fun updateBreedFavorite(breed: Breed) = commonViewModel.updateBreedFavorite(breed) | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,40 @@ | ||
import Combine | ||
import shared | ||
|
||
/// Create a Combine publisher from the supplied `FlowAdapter`. Use this in contexts where more transformation will be | ||
/// done on the Swift side before the value is bound to UI | ||
func createPublisher<T>(_ flowAdapter: FlowAdapter<T>) -> AnyPublisher<T, KotlinError> { | ||
return Deferred<Publishers.HandleEvents<PassthroughSubject<T, KotlinError>>> { | ||
let subject = PassthroughSubject<T, KotlinError>() | ||
let canceller = flowAdapter.subscribe( | ||
onEach: { item in subject.send(item) }, | ||
onComplete: { subject.send(completion: .finished) }, | ||
onThrow: { error in subject.send(completion: .failure(KotlinError(error))) } | ||
) | ||
return subject.handleEvents(receiveCancel: { canceller.cancel() }) | ||
}.eraseToAnyPublisher() | ||
} | ||
|
||
/// Prepare the supplied `FlowAdapter` to be bound to UI. The `onEach` callback will be called from `DispatchQueue.main` | ||
/// on every new emission. | ||
/// | ||
/// Note that this calls `assertNoFailure()` internally so you should handle errors upstream to avoid crashes. | ||
func doPublish<T>(_ flowAdapter: FlowAdapter<T>, onEach: @escaping (T) -> Void) -> Cancellable { | ||
return createPublisher(flowAdapter) | ||
.assertNoFailure() | ||
.compactMap { $0 } | ||
.receive(on: DispatchQueue.main) | ||
.sink { onEach($0) } | ||
} | ||
|
||
/// Wraps a `KotlinThrowable` in a `LocalizedError` which can be used as a Combine error type | ||
class KotlinError: LocalizedError { | ||
let throwable: KotlinThrowable | ||
|
||
init(_ throwable: KotlinThrowable) { | ||
self.throwable = throwable | ||
} | ||
var errorDescription: String? { | ||
throwable.message | ||
} | ||
} |
This file was deleted.
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -2,6 +2,7 @@ package co.touchlab.kampkit | |
|
||
import co.touchlab.kampkit.ktor.DogApi | ||
import co.touchlab.kampkit.ktor.DogApiImpl | ||
import co.touchlab.kampkit.models.BreedRepository | ||
import co.touchlab.kermit.Logger | ||
import co.touchlab.kermit.StaticConfig | ||
import co.touchlab.kermit.platformLogWriter | ||
|
@@ -63,6 +64,16 @@ private val coreModule = module { | |
// See https://github.com/touchlab/Kermit | ||
val baseLogger = Logger(config = StaticConfig(logWriterList = listOf(platformLogWriter())), "KampKit") | ||
factory { (tag: String?) -> if (tag != null) baseLogger.withTag(tag) else baseLogger } | ||
|
||
single { | ||
BreedRepository( | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think it'd be great for readability to have named arguments, especially since this is a widely referenced sample project. |
||
get(), | ||
get(), | ||
get(), | ||
getWith("BreedRepository"), | ||
get() | ||
) | ||
} | ||
} | ||
|
||
internal inline fun <reified T> Scope.getWith(vararg params: Any?): T { | ||
|
This file was deleted.
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,65 @@ | ||
package co.touchlab.kampkit.models | ||
|
||
import co.touchlab.kampkit.db.Breed | ||
import co.touchlab.kermit.Logger | ||
import kotlinx.coroutines.CoroutineScope | ||
import kotlinx.coroutines.FlowPreview | ||
import kotlinx.coroutines.flow.MutableStateFlow | ||
import kotlinx.coroutines.flow.StateFlow | ||
import kotlinx.coroutines.flow.flattenMerge | ||
import kotlinx.coroutines.flow.flowOf | ||
import kotlinx.coroutines.launch | ||
|
||
class BreedCommonViewModel( | ||
private val breedRepository: BreedRepository, | ||
log: Logger, | ||
private val scope: CoroutineScope | ||
) { | ||
private val log = log.withTag("BreedCommonViewModel") | ||
|
||
private val mutableBreeds: MutableStateFlow<DataState<ItemDataSummary>> = MutableStateFlow( | ||
DataState(loading = true) | ||
) | ||
|
||
val breeds: StateFlow<DataState<ItemDataSummary>> = mutableBreeds | ||
|
||
init { | ||
observeBreeds() | ||
} | ||
|
||
@OptIn(FlowPreview::class) | ||
private fun observeBreeds() { | ||
scope.launch { | ||
log.v { "getBreeds: Collecting Things" } | ||
flowOf( | ||
breedRepository.refreshBreedsIfStale(true), | ||
breedRepository.getBreedsFromCache() | ||
).flattenMerge().collect { dataState -> | ||
if (dataState.loading) { | ||
mutableBreeds.value = mutableBreeds.value.copy(loading = true) | ||
} else { | ||
mutableBreeds.value = dataState | ||
} | ||
} | ||
} | ||
} | ||
|
||
fun refreshBreeds() { | ||
scope.launch { | ||
log.v { "refreshBreeds" } | ||
breedRepository.refreshBreedsIfStale(true).collect { dataState -> | ||
if (dataState.loading) { | ||
mutableBreeds.value = mutableBreeds.value.copy(loading = true) | ||
} else { | ||
mutableBreeds.value = dataState | ||
} | ||
} | ||
} | ||
} | ||
|
||
fun updateBreedFavorite(breed: Breed) { | ||
scope.launch { | ||
breedRepository.updateBreedFavorite(breed) | ||
} | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm wondering, do we need to hide the commonViewModel and duplicate it's signature here? Is there any reason not to have this classes responsibility be just creating the common one and making it accessible to callers? I think the main reason would be confusion at the call site
breedViewModel.commonViewModel.refreshBreeds()
is obviously pretty ugly. But with some improved naming, might be worth it to avoid maintaining the duplicated signatures?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In KaMPKit we don't, which is a refactor on my (undocumented, oops) todo list before merging this. In general though, you might want this separation if your platforms are not perfectly in-sync because it gives you a place to put android-specific stuff. It's also a place you can convert to LiveData or RxJava or anything else platform-specific you might be using in existing Android code.