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

ViewModel refactor - common ViewModel by composition #231

Closed
wants to merge 3 commits into from
Closed
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
2 changes: 2 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ android {
dependencies {
implementation(project(":shared"))
implementation(libs.bundles.app.ui)
implementation(libs.multiplatformSettings.common)
implementation(libs.kotlinx.dateTime)
coreLibraryDesugaring(libs.android.desugaring)
implementation(libs.koin.android)
testImplementation(libs.junit)
Expand Down
70 changes: 10 additions & 60 deletions app/src/main/java/co/touchlab/kampkit/android/BreedViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Copy link
Contributor

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?

Copy link
Contributor Author

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.


val breedStateFlow: StateFlow<DataState<ItemDataSummary>> = _breedStateFlow
val breeds = commonViewModel.breeds
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe get() accessor vs keeping a state reference here? Seems like a simple passthrough.


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)
}
3 changes: 0 additions & 3 deletions app/src/main/java/co/touchlab/kampkit/android/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,5 @@ class MainActivity : ComponentActivity(), KoinComponent {
MainScreen(viewModel, log)
}
}
if (viewModel.breedStateFlow.value.data == null) {
viewModel.refreshBreeds()
}
}
}
3 changes: 2 additions & 1 deletion app/src/main/java/co/touchlab/kampkit/android/MainApp.kt
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import android.util.Log
import co.touchlab.kampkit.AppInfo
import co.touchlab.kampkit.initKoin
import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.core.parameter.parametersOf
import org.koin.dsl.module

class MainApp : Application() {
Expand All @@ -16,7 +17,7 @@ class MainApp : Application() {
initKoin(
module {
single<Context> { this@MainApp }
viewModel { BreedViewModel() }
viewModel { BreedViewModel(get(), get { parametersOf("BreedViewModel") }) }
single<SharedPreferences> {
get<Context>().getSharedPreferences("KAMPSTARTER_SETTINGS", Context.MODE_PRIVATE)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,16 +47,16 @@ fun MainScreen(
log: Logger
) {
val lifecycleOwner = LocalLifecycleOwner.current
val lifecycleAwareDogsFlow = remember(viewModel.breedStateFlow, lifecycleOwner) {
viewModel.breedStateFlow.flowWithLifecycle(lifecycleOwner.lifecycle)
val lifecycleAwareDogsFlow = remember(viewModel.breeds, lifecycleOwner) {
viewModel.breeds.flowWithLifecycle(lifecycleOwner.lifecycle)
}

@SuppressLint("StateFlowValueCalledInComposition") // False positive lint check when used inside collectAsState()
val dogsState by lifecycleAwareDogsFlow.collectAsState(viewModel.breedStateFlow.value)
val dogsState by lifecycleAwareDogsFlow.collectAsState(viewModel.breeds.value)

MainScreenContent(
dogsState = dogsState,
onRefresh = { viewModel.refreshBreeds(true) },
onRefresh = { viewModel.refreshBreeds() },
onSuccess = { data -> log.v { "View updating with ${data.allItems.size} breeds" } },
onError = { exception -> log.e { "Displaying error: $exception" } },
onFavorite = { viewModel.updateBreedFavorite(it) }
Expand Down
4 changes: 4 additions & 0 deletions ios/KaMPKitiOS.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

/* Begin PBXBuildFile section */
3DFF917C64A18A83DA010EE1 /* Pods_KaMPKitiOS.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B859F3FB23133D22AB9DD835 /* Pods_KaMPKitiOS.framework */; };
461C74AA2788F5F3004B1FFC /* CombineAdapters.swift in Sources */ = {isa = PBXBuildFile; fileRef = 461C74A92788F5F3004B1FFC /* CombineAdapters.swift */; };
46A5B5EF26AF54F7002EFEAA /* BreedListScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46A5B5EE26AF54F7002EFEAA /* BreedListScreen.swift */; };
46A5B60826B04921002EFEAA /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 46A5B60626B04920002EFEAA /* Main.storyboard */; };
46B5284D249C5CF400A7725D /* Koin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46B5284C249C5CF400A7725D /* Koin.swift */; };
Expand Down Expand Up @@ -38,6 +39,7 @@
/* Begin PBXFileReference section */
1DFCC00C8DAA719770A18D1A /* Pods-KaMPKitiOS.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-KaMPKitiOS.release.xcconfig"; path = "Pods/Target Support Files/Pods-KaMPKitiOS/Pods-KaMPKitiOS.release.xcconfig"; sourceTree = "<group>"; };
2A1ED6A4A2A53F5F75C58E5F /* Pods-KaMPKitiOS.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-KaMPKitiOS.release.xcconfig"; path = "Pods/Target Support Files/Pods-KaMPKitiOS/Pods-KaMPKitiOS.release.xcconfig"; sourceTree = "<group>"; };
461C74A92788F5F3004B1FFC /* CombineAdapters.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CombineAdapters.swift; sourceTree = "<group>"; };
46A5B5EE26AF54F7002EFEAA /* BreedListScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BreedListScreen.swift; sourceTree = "<group>"; };
46A5B60726B04920002EFEAA /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
46B5284C249C5CF400A7725D /* Koin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Koin.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -134,6 +136,7 @@
F1465F0B23AA94BF0055F7C3 /* LaunchScreen.storyboard */,
F1465F0E23AA94BF0055F7C3 /* Info.plist */,
46A5B5EE26AF54F7002EFEAA /* BreedListScreen.swift */,
461C74A92788F5F3004B1FFC /* CombineAdapters.swift */,
);
path = KaMPKitiOS;
sourceTree = "<group>";
Expand Down Expand Up @@ -351,6 +354,7 @@
buildActionMask = 2147483647;
files = (
46B5284D249C5CF400A7725D /* Koin.swift in Sources */,
461C74AA2788F5F3004B1FFC /* CombineAdapters.swift in Sources */,
46A5B5EF26AF54F7002EFEAA /* BreedListScreen.swift in Sources */,
F1465F0123AA94BF0055F7C3 /* AppDelegate.swift in Sources */,
);
Expand Down
1 change: 0 additions & 1 deletion ios/KaMPKitiOS/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
var window: UIWindow?

// Lazy so it doesn't try to initialize before startKoin() is called
// swiftlint:disable force_cast
lazy var log = koin.loggerWithTag(tag: "AppDelegate")

func application(_ application: UIApplication, didFinishLaunchingWithOptions
Expand Down
21 changes: 15 additions & 6 deletions ios/KaMPKitiOS/BreedListScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,14 @@
// Copyright © 2021 Touchlab. All rights reserved.
//

import Combine
import SwiftUI
import shared

// swiftlint:disable force_cast
private let log = koin.loggerWithTag(tag: "ViewController")

class ObservableBreedModel: ObservableObject {
private var viewModel: NativeViewModel?
private var viewModel: BreedCallbackViewModel?

@Published
var loading = false
Expand All @@ -24,8 +24,12 @@ class ObservableBreedModel: ObservableObject {
@Published
var error: String?

private var cancellables = [AnyCancellable]()

func activate() {
viewModel = NativeViewModel { [weak self] dataState in
let viewModel = KotlinDependencies.shared.getBreedViewModel()

doPublish(viewModel.breeds) { [weak self] dataState in
self?.loading = dataState.loading
self?.breeds = dataState.data?.allItems
self?.error = dataState.exception
Expand All @@ -36,11 +40,16 @@ class ObservableBreedModel: ObservableObject {
if let errorMessage = dataState.exception {
log.e(message: {"Displaying error: \(errorMessage)"})
}
}
}.store(in: &cancellables)

self.viewModel = viewModel
}

func deactivate() {
viewModel?.onDestroy()
cancellables.forEach { $0.cancel() }
cancellables.removeAll()

viewModel?.clear()
viewModel = nil
}

Expand All @@ -49,7 +58,7 @@ class ObservableBreedModel: ObservableObject {
}

func refresh() {
viewModel?.refreshBreeds(forced: true)
viewModel?.refreshBreeds()
}
}

Expand Down
40 changes: 40 additions & 0 deletions ios/KaMPKitiOS/CombineAdapters.swift
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.

11 changes: 11 additions & 0 deletions shared/src/commonMain/kotlin/co/touchlab/kampkit/Koin.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(

Choose a reason for hiding this comment

The 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 {
Expand Down
3 changes: 0 additions & 3 deletions shared/src/commonMain/kotlin/co/touchlab/kampkit/Platform.kt

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)
}
}
}
Loading