From 037b093ec5fb68b83e2eb3635051fabe5c694d2e Mon Sep 17 00:00:00 2001 From: takahirom Date: Fri, 19 Nov 2021 08:28:19 +0900 Subject: [PATCH 1/7] Try to use molecule --- android/build.gradle | 14 +- .../droidkaigi/feeder/CoroutineTestRule.kt | 31 ++++ .../droidkaigi/feeder/FeedViewModelTest.kt | 83 +++++++---- build.gradle | 1 + buildSrc/src/main/java/Dep.kt | 7 + uicomponent-compose/core/build.gradle | 1 + .../lifecycle/viewModelScopeWithClock.kt | 34 +++++ uicomponent-compose/main/build.gradle | 6 +- .../feeder/viewmodel/RealFeedViewModel.kt | 141 +++++++++++------- 9 files changed, 221 insertions(+), 97 deletions(-) create mode 100644 uicomponent-compose/core/src/main/java/androidx/lifecycle/viewModelScopeWithClock.kt diff --git a/android/build.gradle b/android/build.gradle index f887e61e8..85140344d 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -46,7 +46,7 @@ android { storeFile file("release.keystore") storePassword props['storePassword'] keyAlias props['keyAlias'] - keyPassword props['keyPassword'] + keyPassword props['keyPassword'] } } } @@ -68,7 +68,8 @@ android { } } compileOptions { - coreLibraryDesugaringEnabled true // https://github.com/DroidKaigi/conference-app-2021/issues/373 + // https://github.com/DroidKaigi/conference-app-2021/issues/373 + coreLibraryDesugaringEnabled true sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 } @@ -131,7 +132,12 @@ dependencies { implementation Dep.Kotlin.bom implementation Dep.Kotlin.stdlib - implementation (Dep.Coroutines.core) { + implementation(Dep.Coroutines.core) { + version { + strictly Versions.coroutines + } + } + testImplementation(Dep.Coroutines.core) { version { strictly Versions.coroutines } @@ -165,7 +171,7 @@ dependencies { testImplementation 'io.kotest:kotest-assertions-core:4.3.2' // https://github.com/cashapp/turbine/issues/10 testImplementation 'app.cash.turbine:turbine:0.2.1' - + testImplementation Dep.turbine androidTestImplementation Dep.Jetpack.Test.ext androidTestImplementation Dep.Jetpack.Test.espresso } diff --git a/android/src/test/java/io/github/droidkaigi/feeder/CoroutineTestRule.kt b/android/src/test/java/io/github/droidkaigi/feeder/CoroutineTestRule.kt index 39f6fbbb9..f1e31a7ef 100644 --- a/android/src/test/java/io/github/droidkaigi/feeder/CoroutineTestRule.kt +++ b/android/src/test/java/io/github/droidkaigi/feeder/CoroutineTestRule.kt @@ -1,19 +1,34 @@ package io.github.droidkaigi.feeder +import androidx.compose.runtime.BroadcastFrameClock +import androidx.compose.runtime.withFrameMillis +import androidx.lifecycle.overrideDefaultContext +import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch import kotlinx.coroutines.test.TestCoroutineDispatcher import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.setMain +import kotlinx.coroutines.yield import org.junit.rules.TestWatcher import org.junit.runner.Description @ExperimentalCoroutinesApi class CoroutineTestRule(val testDispatcher: TestCoroutineDispatcher = TestCoroutineDispatcher()) : TestWatcher() { + private lateinit var clock: BroadcastFrameClock + + suspend fun awaitFrame(){ + yield() + clock.awaitFrame() + } override fun starting(description: Description?) { super.starting(description) + clock = BroadcastFrameClock() + overrideDefaultContext = clock + testDispatcher Dispatchers.setMain(testDispatcher) } @@ -23,3 +38,19 @@ class CoroutineTestRule(val testDispatcher: TestCoroutineDispatcher = TestCorout testDispatcher.cleanupTestCoroutines() } } + +// from: molecule +private suspend fun BroadcastFrameClock.awaitFrame() { + // TODO Remove the need for two frames to happen! + // I think this is because of the diff-sender is a hot loop that immediately reschedules + // itself on the clock. This schedules it ahead of the coroutine which applies changes and + // so we need to trigger an additional frame to actually emit the change's diffs. + repeat(2) { + coroutineScope { + launch(start = CoroutineStart.UNDISPATCHED) { + withFrameMillis { } + } + sendFrame(0L) + } + } +} diff --git a/android/src/test/java/io/github/droidkaigi/feeder/FeedViewModelTest.kt b/android/src/test/java/io/github/droidkaigi/feeder/FeedViewModelTest.kt index 7a1703132..23242048e 100644 --- a/android/src/test/java/io/github/droidkaigi/feeder/FeedViewModelTest.kt +++ b/android/src/test/java/io/github/droidkaigi/feeder/FeedViewModelTest.kt @@ -1,5 +1,6 @@ package io.github.droidkaigi.feeder +import app.cash.turbine.test import io.github.droidkaigi.feeder.data.FeedRepositoryImpl import io.github.droidkaigi.feeder.data.fakeFeedApi import io.github.droidkaigi.feeder.data.fakeFeedItemDao @@ -32,63 +33,79 @@ class FeedViewModelTest( val coroutineTestRule = CoroutineTestRule() @Test - fun contents() = coroutineTestRule.testDispatcher.runBlockingTest { - // Replace when it fixed https://github.com/cashapp/turbine/issues/10 - val feedViewModel = feedViewModelFactory.create() - - val firstContent = feedViewModel.state.value.filteredFeedContents - - firstContent.size shouldBeGreaterThan 1 + fun contents() { + coroutineTestRule.testDispatcher.runBlockingTest { + val feedViewModel = feedViewModelFactory.create() + feedViewModel.state.test { + coroutineTestRule.awaitFrame() + expectMostRecentItem().filteredFeedContents.size shouldBeGreaterThan 1 + } + } } @Test fun favorite_Add() = coroutineTestRule.testDispatcher.runBlockingTest { val feedViewModel = feedViewModelFactory.create() - val firstContent = feedViewModel.state.value.filteredFeedContents - firstContent.favorites shouldBe setOf() + feedViewModel.state.test { + coroutineTestRule.awaitFrame() + val firstContent = expectMostRecentItem().filteredFeedContents + firstContent.favorites shouldBe setOf() - feedViewModel.event(ToggleFavorite(firstContent.feedItemContents[0])) + feedViewModel.event(ToggleFavorite(firstContent.feedItemContents[0])) - val secondContent = feedViewModel.state.value.filteredFeedContents - secondContent.favorites shouldBe setOf(firstContent.feedItemContents[0].id) + coroutineTestRule.awaitFrame() + val secondContent = awaitItem().filteredFeedContents + secondContent.favorites shouldBe setOf(firstContent.feedItemContents[0].id) + } } @Test fun favorite_Remove() = coroutineTestRule.testDispatcher.runBlockingTest { val feedViewModel = feedViewModelFactory.create() - val firstContent = feedViewModel.state.value.filteredFeedContents - firstContent.favorites shouldBe setOf() - - feedViewModel.event(ToggleFavorite(feedItem = firstContent.feedItemContents[0])) - feedViewModel.event(ToggleFavorite(feedItem = firstContent.feedItemContents[0])) - - val secondContent = feedViewModel.state.value.filteredFeedContents - secondContent.favorites shouldBe setOf() + feedViewModel.state.test { + coroutineTestRule.awaitFrame() + val firstContent = expectMostRecentItem().filteredFeedContents + firstContent.favorites shouldBe setOf() + + feedViewModel.event(ToggleFavorite(feedItem = firstContent.feedItemContents[0])) + coroutineTestRule.awaitFrame() + feedViewModel.event(ToggleFavorite(feedItem = firstContent.feedItemContents[0])) + coroutineTestRule.awaitFrame() + + val secondContent = expectMostRecentItem().filteredFeedContents + secondContent.favorites shouldBe setOf() + } } @Test fun favorite_Filter() = coroutineTestRule.testDispatcher.runBlockingTest { val feedViewModel = feedViewModelFactory.create() - val firstContent = feedViewModel.state.value.filteredFeedContents - firstContent.favorites shouldBe setOf() - val favoriteContents = firstContent.feedItemContents[1] - - feedViewModel.event(ToggleFavorite(feedItem = favoriteContents)) - feedViewModel.event(ChangeFavoriteFilter(Filters(filterFavorite = true))) - - val secondContent = feedViewModel.state.value.filteredFeedContents - secondContent.contents[0].first.id shouldBe favoriteContents.id + feedViewModel.state.test { + coroutineTestRule.awaitFrame() + val firstContent = expectMostRecentItem().filteredFeedContents + firstContent.favorites shouldBe setOf() + val favoriteContents = firstContent.feedItemContents[1] + + feedViewModel.event(ToggleFavorite(feedItem = favoriteContents)) + feedViewModel.event(ChangeFavoriteFilter(Filters(filterFavorite = true))) + coroutineTestRule.awaitFrame() + + val secondContent = expectMostRecentItem().filteredFeedContents + secondContent.contents[0].first.id shouldBe favoriteContents.id + } } @Test fun errorWhenFetch() = coroutineTestRule.testDispatcher.runBlockingTest { val feedViewModel = feedViewModelFactory.create(errorFetchData = true) - val firstContent = feedViewModel.state.value.filteredFeedContents - firstContent.favorites shouldBe setOf() + feedViewModel.state.test { + val firstContent = expectMostRecentItem().filteredFeedContents + firstContent.favorites shouldBe setOf() - val firstEffect = feedViewModel.effect.first() + val firstEffect = feedViewModel.effect.first() - firstEffect.shouldBeInstanceOf() + firstEffect.shouldBeInstanceOf() + } } class FeedViewModelFactory( diff --git a/build.gradle b/build.gradle index da00633d7..2ef5edd06 100644 --- a/build.gradle +++ b/build.gradle @@ -27,6 +27,7 @@ buildscript { classpath Dep.SQLDelight.plugin classpath Dep.playServicesOssLicensesPlugin classpath Dep.buildKonfig + classpath Dep.Molecule.plugin } } diff --git a/buildSrc/src/main/java/Dep.kt b/buildSrc/src/main/java/Dep.kt index 16692b22b..104fa46fe 100644 --- a/buildSrc/src/main/java/Dep.kt +++ b/buildSrc/src/main/java/Dep.kt @@ -150,4 +150,11 @@ object Dep { const val desugarJdkLibs = "com.android.tools:desugar_jdk_libs:1.1.5" const val napier = "io.github.aakira:napier:2.1.0" + + object Molecule { + const val plugin = "app.cash.molecule:molecule-gradle-plugin:0.1.0" + const val runtime = "app.cash.molecule:molecule-runtime" + const val testing = "app.cash.molecule:molecule-testing:0.1.0" + } + val turbine = "app.cash.turbine:turbine:0.7.0" } diff --git a/uicomponent-compose/core/build.gradle b/uicomponent-compose/core/build.gradle index ee74bace0..d67640f5f 100644 --- a/uicomponent-compose/core/build.gradle +++ b/uicomponent-compose/core/build.gradle @@ -7,6 +7,7 @@ plugins { id 'app.cash.exhaustive' id 'kotlin-kapt' id 'dagger.hilt.android.plugin' + id 'app.cash.molecule' } apply from: rootProject.file("gradle/android.gradle") diff --git a/uicomponent-compose/core/src/main/java/androidx/lifecycle/viewModelScopeWithClock.kt b/uicomponent-compose/core/src/main/java/androidx/lifecycle/viewModelScopeWithClock.kt new file mode 100644 index 000000000..4a1a96448 --- /dev/null +++ b/uicomponent-compose/core/src/main/java/androidx/lifecycle/viewModelScopeWithClock.kt @@ -0,0 +1,34 @@ +package androidx.lifecycle + +import app.cash.molecule.AndroidUiDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import java.io.Closeable +import kotlin.coroutines.CoroutineContext + +private const val JOB_KEY = "io.github.droidkaigi.feeder.ViewModelCoroutineScope.JOB_KEY" + +val defaultDispatcher by lazy { AndroidUiDispatcher.Main } +var overrideDefaultContext: CoroutineContext? = null + +public val ViewModel.viewModelScopeWithClock: CoroutineScope + get() { + val scope: CoroutineScope? = this.getTag(JOB_KEY) + if (scope != null) { + return scope + } + return setTagIfAbsent( + JOB_KEY, + CloseableCoroutineScope(SupervisorJob() + + (overrideDefaultContext ?: defaultDispatcher)) + ) + } + +internal class CloseableCoroutineScope(context: CoroutineContext) : Closeable, CoroutineScope { + override val coroutineContext: CoroutineContext = context + + override fun close() { + coroutineContext.cancel() + } +} diff --git a/uicomponent-compose/main/build.gradle b/uicomponent-compose/main/build.gradle index e40aa530d..c10673bb2 100644 --- a/uicomponent-compose/main/build.gradle +++ b/uicomponent-compose/main/build.gradle @@ -7,6 +7,7 @@ plugins { id 'kotlin-kapt' id 'app.cash.exhaustive' id 'dagger.hilt.android.plugin' + id 'app.cash.molecule' } apply from: rootProject.file("gradle/android.gradle") @@ -14,7 +15,8 @@ apply from: rootProject.file("gradle/compose.gradle") android { compileOptions { - coreLibraryDesugaringEnabled true // need for test. https://github.com/DroidKaigi/conference-app-2021/issues/373 + coreLibraryDesugaringEnabled true + // need for test. https://github.com/DroidKaigi/conference-app-2021/issues/373 sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 } @@ -50,7 +52,7 @@ dependencies { implementation Dep.Accompanist.insets implementation Dep.Accompanist.systemuicontroller - implementation (Dep.Coroutines.core) { + implementation(Dep.Coroutines.core) { version { strictly Versions.coroutines } diff --git a/uicomponent-compose/main/src/main/java/io/github/droidkaigi/feeder/viewmodel/RealFeedViewModel.kt b/uicomponent-compose/main/src/main/java/io/github/droidkaigi/feeder/viewmodel/RealFeedViewModel.kt index 47e6f85ec..568b5b10b 100644 --- a/uicomponent-compose/main/src/main/java/io/github/droidkaigi/feeder/viewmodel/RealFeedViewModel.kt +++ b/uicomponent-compose/main/src/main/java/io/github/droidkaigi/feeder/viewmodel/RealFeedViewModel.kt @@ -1,7 +1,20 @@ package io.github.droidkaigi.feeder.viewmodel +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewModelScopeWithClock +import app.cash.molecule.launchMolecule import dagger.hilt.android.lifecycle.HiltViewModel import io.github.droidkaigi.feeder.AppError import io.github.droidkaigi.feeder.FeedContents @@ -13,23 +26,22 @@ import io.github.droidkaigi.feeder.getContents import io.github.droidkaigi.feeder.orEmptyContents import io.github.droidkaigi.feeder.repository.FeedRepository import io.github.droidkaigi.feeder.toLoadState -import javax.annotation.meta.Exhaustive -import javax.inject.Inject import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.receiveAsFlow -import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import javax.annotation.meta.Exhaustive +import javax.inject.Inject @HiltViewModel class RealFeedViewModel @Inject constructor( private val feedRepository: FeedRepository, ) : ViewModel(), FeedViewModel { + private val eventFlow = MutableSharedFlow() private val effectChannel = Channel(Channel.UNLIMITED) private val showProgressLatch = ProgressTimeLatch(viewModelScope = viewModelScope) @@ -41,65 +53,78 @@ class RealFeedViewModel @Inject constructor( } } - private val allFeedContents: StateFlow> = feedRepository.feedContents() - .toLoadState() - .onEach { loadState -> - if (loadState.isError()) { - // FIXME: smartcast is not working - val error = loadState as LoadState.Error - error.getThrowableOrNull()?.printStackTrace() - effectChannel.send(FeedViewModel.Effect.ErrorMessage(error.e)) - } - showProgressLatch.refresh(loadState.isLoading()) + val flow = feedRepository.feedContents().toLoadState() + + @Composable + fun contentsLoadState(): State> { + return produceState>(initialValue = LoadState.Loading) { + println("launch") + feedRepository.feedContents() + .catch { value = LoadState.Error(it) } + .collect{ + println("collect") + value = LoadState.Loaded(it) + } } - .stateIn(viewModelScope, SharingStarted.Lazily, LoadState.Loading) - private val filters: MutableStateFlow = MutableStateFlow(Filters()) + } - override val state: StateFlow = - combine( - allFeedContents, - filters, - showProgressLatch.toggleState, - ) { feedContentsLoadState, filters, showProgress -> - val filteredFeed = - feedContentsLoadState.getValueOrNull().orEmptyContents().filtered(filters) - FeedViewModel.State( - showProgress = showProgress, - filters = filters, - filteredFeedContents = filteredFeed, -// snackbarMessage = currentValue.snackbarMessage - ) + override val state: StateFlow = viewModelScopeWithClock.launchMolecule { + val feedContentsLoadState by contentsLoadState() + val showProgress by showProgressLatch.toggleState.collectAsState() + var filters by remember { mutableStateOf(Filters()) } + val filteredFeed by derivedStateOf { + feedContentsLoadState.getValueOrNull().orEmptyContents() + .filtered(filters) } - .stateIn( - scope = viewModelScope, - // prefetch when splash screen - started = SharingStarted.Eagerly, - initialValue = FeedViewModel.State() - ) - override fun event(event: FeedViewModel.Event) { - viewModelScope.launch { - @Exhaustive - when (event) { - is FeedViewModel.Event.ChangeFavoriteFilter -> { - filters.value = event.filters - } - is FeedViewModel.Event.ToggleFavorite -> { - val favorite = allFeedContents.value - .getContents() - .favorites - .contains(event.feedItem.id) - if (favorite) { - feedRepository.removeFavorite(event.feedItem.id) - } else { - feedRepository.addFavorite(event.feedItem.id) + LaunchedEffect(Unit) { + eventFlow.collect { event -> + @Exhaustive + when (event) { + is FeedViewModel.Event.ChangeFavoriteFilter -> { + filters = event.filters + } + is FeedViewModel.Event.ToggleFavorite -> { + val favorite = feedContentsLoadState + .getContents() + .favorites + .contains(event.feedItem.id) + if (favorite) { + feedRepository.removeFavorite(event.feedItem.id) + } else { + feedRepository.addFavorite(event.feedItem.id) + } + } + is FeedViewModel.Event.ReloadContent -> { + refreshRepository() } - } - is FeedViewModel.Event.ReloadContent -> { - refreshRepository() } } } + LaunchedEffect(Unit) { + snapshotFlow { feedContentsLoadState } + .collect { loadState -> + if (loadState.isError()) { + // FIXME: smartcast is not working + val error = loadState as LoadState.Error + error.getThrowableOrNull()?.printStackTrace() + effectChannel.send(FeedViewModel.Effect.ErrorMessage(error.e)) + } + showProgressLatch.refresh(loadState.isLoading()) + } + } + + FeedViewModel.State( + showProgress = showProgress, + filters = filters, + filteredFeedContents = filteredFeed, + ) + } + + override fun event(event: FeedViewModel.Event) { + viewModelScopeWithClock.launch { + eventFlow.emit(event) + } } private suspend fun refreshRepository() { From dd0d4c0e088012f8c160f7ee72d1fb75d100e71c Mon Sep 17 00:00:00 2001 From: takahirom Date: Fri, 19 Nov 2021 08:31:16 +0900 Subject: [PATCH 2/7] Remove unneeded contentsLoadState --- .../feeder/viewmodel/RealFeedViewModel.kt | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/uicomponent-compose/main/src/main/java/io/github/droidkaigi/feeder/viewmodel/RealFeedViewModel.kt b/uicomponent-compose/main/src/main/java/io/github/droidkaigi/feeder/viewmodel/RealFeedViewModel.kt index 568b5b10b..5b3644261 100644 --- a/uicomponent-compose/main/src/main/java/io/github/droidkaigi/feeder/viewmodel/RealFeedViewModel.kt +++ b/uicomponent-compose/main/src/main/java/io/github/droidkaigi/feeder/viewmodel/RealFeedViewModel.kt @@ -1,8 +1,6 @@ package io.github.droidkaigi.feeder.viewmodel -import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.State import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue @@ -55,21 +53,14 @@ class RealFeedViewModel @Inject constructor( val flow = feedRepository.feedContents().toLoadState() - @Composable - fun contentsLoadState(): State> { - return produceState>(initialValue = LoadState.Loading) { - println("launch") + override val state: StateFlow = viewModelScopeWithClock.launchMolecule { + val feedContentsLoadState by produceState>(initialValue = LoadState.Loading) { feedRepository.feedContents() .catch { value = LoadState.Error(it) } - .collect{ - println("collect") + .collect { value = LoadState.Loaded(it) } } - } - - override val state: StateFlow = viewModelScopeWithClock.launchMolecule { - val feedContentsLoadState by contentsLoadState() val showProgress by showProgressLatch.toggleState.collectAsState() var filters by remember { mutableStateOf(Filters()) } val filteredFeed by derivedStateOf { From 0891ac8e144e750a0229656d40b86ef1115184c1 Mon Sep 17 00:00:00 2001 From: takahirom Date: Fri, 19 Nov 2021 08:43:07 +0900 Subject: [PATCH 3/7] Apply spotless --- .../io/github/droidkaigi/feeder/CoroutineTestRule.kt | 2 +- .../java/androidx/lifecycle/viewModelScopeWithClock.kt | 10 ++++++---- .../droidkaigi/feeder/viewmodel/RealFeedViewModel.kt | 8 +++++--- 3 files changed, 12 insertions(+), 8 deletions(-) diff --git a/android/src/test/java/io/github/droidkaigi/feeder/CoroutineTestRule.kt b/android/src/test/java/io/github/droidkaigi/feeder/CoroutineTestRule.kt index f1e31a7ef..a24a641ab 100644 --- a/android/src/test/java/io/github/droidkaigi/feeder/CoroutineTestRule.kt +++ b/android/src/test/java/io/github/droidkaigi/feeder/CoroutineTestRule.kt @@ -20,7 +20,7 @@ class CoroutineTestRule(val testDispatcher: TestCoroutineDispatcher = TestCorout TestWatcher() { private lateinit var clock: BroadcastFrameClock - suspend fun awaitFrame(){ + suspend fun awaitFrame() { yield() clock.awaitFrame() } diff --git a/uicomponent-compose/core/src/main/java/androidx/lifecycle/viewModelScopeWithClock.kt b/uicomponent-compose/core/src/main/java/androidx/lifecycle/viewModelScopeWithClock.kt index 4a1a96448..160575eaa 100644 --- a/uicomponent-compose/core/src/main/java/androidx/lifecycle/viewModelScopeWithClock.kt +++ b/uicomponent-compose/core/src/main/java/androidx/lifecycle/viewModelScopeWithClock.kt @@ -1,11 +1,11 @@ package androidx.lifecycle import app.cash.molecule.AndroidUiDispatcher +import java.io.Closeable +import kotlin.coroutines.CoroutineContext import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel -import java.io.Closeable -import kotlin.coroutines.CoroutineContext private const val JOB_KEY = "io.github.droidkaigi.feeder.ViewModelCoroutineScope.JOB_KEY" @@ -20,8 +20,10 @@ public val ViewModel.viewModelScopeWithClock: CoroutineScope } return setTagIfAbsent( JOB_KEY, - CloseableCoroutineScope(SupervisorJob() + - (overrideDefaultContext ?: defaultDispatcher)) + CloseableCoroutineScope( + SupervisorJob() + + (overrideDefaultContext ?: defaultDispatcher) + ) ) } diff --git a/uicomponent-compose/main/src/main/java/io/github/droidkaigi/feeder/viewmodel/RealFeedViewModel.kt b/uicomponent-compose/main/src/main/java/io/github/droidkaigi/feeder/viewmodel/RealFeedViewModel.kt index 5b3644261..579ea6487 100644 --- a/uicomponent-compose/main/src/main/java/io/github/droidkaigi/feeder/viewmodel/RealFeedViewModel.kt +++ b/uicomponent-compose/main/src/main/java/io/github/droidkaigi/feeder/viewmodel/RealFeedViewModel.kt @@ -24,6 +24,8 @@ import io.github.droidkaigi.feeder.getContents import io.github.droidkaigi.feeder.orEmptyContents import io.github.droidkaigi.feeder.repository.FeedRepository import io.github.droidkaigi.feeder.toLoadState +import javax.annotation.meta.Exhaustive +import javax.inject.Inject import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow @@ -32,8 +34,6 @@ import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch -import javax.annotation.meta.Exhaustive -import javax.inject.Inject @HiltViewModel class RealFeedViewModel @Inject constructor( @@ -54,7 +54,9 @@ class RealFeedViewModel @Inject constructor( val flow = feedRepository.feedContents().toLoadState() override val state: StateFlow = viewModelScopeWithClock.launchMolecule { - val feedContentsLoadState by produceState>(initialValue = LoadState.Loading) { + val feedContentsLoadState by produceState>( + initialValue = LoadState.Loading + ) { feedRepository.feedContents() .catch { value = LoadState.Error(it) } .collect { From 01a457402cb9d03563a51536fe43f057d62f1624 Mon Sep 17 00:00:00 2001 From: takahirom Date: Fri, 19 Nov 2021 08:46:02 +0900 Subject: [PATCH 4/7] Clean up overrideDefaultContext --- .../test/java/io/github/droidkaigi/feeder/CoroutineTestRule.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/android/src/test/java/io/github/droidkaigi/feeder/CoroutineTestRule.kt b/android/src/test/java/io/github/droidkaigi/feeder/CoroutineTestRule.kt index a24a641ab..f962fc3ab 100644 --- a/android/src/test/java/io/github/droidkaigi/feeder/CoroutineTestRule.kt +++ b/android/src/test/java/io/github/droidkaigi/feeder/CoroutineTestRule.kt @@ -36,6 +36,7 @@ class CoroutineTestRule(val testDispatcher: TestCoroutineDispatcher = TestCorout super.finished(description) Dispatchers.resetMain() testDispatcher.cleanupTestCoroutines() + overrideDefaultContext = null } } From 4c168abc093506dd08db2b013933f269b02465c5 Mon Sep 17 00:00:00 2001 From: takahirom Date: Fri, 19 Nov 2021 08:56:03 +0900 Subject: [PATCH 5/7] Fix compose:core version --- uicomponent-compose/core/build.gradle | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/uicomponent-compose/core/build.gradle b/uicomponent-compose/core/build.gradle index d67640f5f..59a30f528 100644 --- a/uicomponent-compose/core/build.gradle +++ b/uicomponent-compose/core/build.gradle @@ -43,6 +43,11 @@ dependencies { strictly Versions.coroutines } } + testImplementation (Dep.Coroutines.core) { + version { + strictly Versions.coroutines + } + } // Write here to get from JetNews // https://github.com/android/compose-samples/blob/master/JetNews/app/build.gradle#L66 From bdb42dc87361ae8dea0b92e9b6c881d1a04c5eba Mon Sep 17 00:00:00 2001 From: takahirom Date: Fri, 19 Nov 2021 09:11:02 +0900 Subject: [PATCH 6/7] Fix lint --- uicomponent-compose/main/build.gradle | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/uicomponent-compose/main/build.gradle b/uicomponent-compose/main/build.gradle index c10673bb2..e05f406f1 100644 --- a/uicomponent-compose/main/build.gradle +++ b/uicomponent-compose/main/build.gradle @@ -57,6 +57,11 @@ dependencies { strictly Versions.coroutines } } + testImplementation(Dep.Coroutines.core) { + version { + strictly Versions.coroutines + } + } // Write here to get from JetNews // https://github.com/android/compose-samples/blob/master/JetNews/app/build.gradle#L66 From 606a89b2a29e0aa221381d9b98fd3b3301350b42 Mon Sep 17 00:00:00 2001 From: takahirom Date: Fri, 19 Nov 2021 09:46:12 +0900 Subject: [PATCH 7/7] Utilize collectAsLoadState --- .../droidkaigi/feeder/core/util/Flows.kt | 18 ++++++++++++++++++ .../feeder/viewmodel/RealFeedViewModel.kt | 17 ++--------------- 2 files changed, 20 insertions(+), 15 deletions(-) diff --git a/uicomponent-compose/core/src/main/java/io/github/droidkaigi/feeder/core/util/Flows.kt b/uicomponent-compose/core/src/main/java/io/github/droidkaigi/feeder/core/util/Flows.kt index 793162694..61bc1804f 100644 --- a/uicomponent-compose/core/src/main/java/io/github/droidkaigi/feeder/core/util/Flows.kt +++ b/uicomponent-compose/core/src/main/java/io/github/droidkaigi/feeder/core/util/Flows.kt @@ -2,7 +2,11 @@ package io.github.droidkaigi.feeder.core.util import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State +import androidx.compose.runtime.produceState +import io.github.droidkaigi.feeder.LoadState import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.collect @Suppress("ComposableNaming") @@ -13,3 +17,17 @@ fun Flow.collectInLaunchedEffect(function: suspend (value: T) -> Unit) { flow.collect(function) } } + +@Composable +fun Flow.collectAsLoadState(): State> { + val flow = this + return produceState>( + initialValue = LoadState.Loading + ) { + flow + .catch { value = LoadState.Error(it) } + .collect { + value = LoadState.Loaded(it) + } + } +} diff --git a/uicomponent-compose/main/src/main/java/io/github/droidkaigi/feeder/viewmodel/RealFeedViewModel.kt b/uicomponent-compose/main/src/main/java/io/github/droidkaigi/feeder/viewmodel/RealFeedViewModel.kt index 579ea6487..eb3350035 100644 --- a/uicomponent-compose/main/src/main/java/io/github/droidkaigi/feeder/viewmodel/RealFeedViewModel.kt +++ b/uicomponent-compose/main/src/main/java/io/github/droidkaigi/feeder/viewmodel/RealFeedViewModel.kt @@ -5,7 +5,6 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow @@ -15,22 +14,20 @@ import androidx.lifecycle.viewModelScopeWithClock import app.cash.molecule.launchMolecule import dagger.hilt.android.lifecycle.HiltViewModel import io.github.droidkaigi.feeder.AppError -import io.github.droidkaigi.feeder.FeedContents import io.github.droidkaigi.feeder.Filters import io.github.droidkaigi.feeder.LoadState import io.github.droidkaigi.feeder.core.util.ProgressTimeLatch +import io.github.droidkaigi.feeder.core.util.collectAsLoadState import io.github.droidkaigi.feeder.feed.FeedViewModel import io.github.droidkaigi.feeder.getContents import io.github.droidkaigi.feeder.orEmptyContents import io.github.droidkaigi.feeder.repository.FeedRepository -import io.github.droidkaigi.feeder.toLoadState import javax.annotation.meta.Exhaustive import javax.inject.Inject import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch @@ -51,18 +48,8 @@ class RealFeedViewModel @Inject constructor( } } - val flow = feedRepository.feedContents().toLoadState() - override val state: StateFlow = viewModelScopeWithClock.launchMolecule { - val feedContentsLoadState by produceState>( - initialValue = LoadState.Loading - ) { - feedRepository.feedContents() - .catch { value = LoadState.Error(it) } - .collect { - value = LoadState.Loaded(it) - } - } + val feedContentsLoadState by feedRepository.feedContents().collectAsLoadState() val showProgress by showProgressLatch.toggleState.collectAsState() var filters by remember { mutableStateOf(Filters()) } val filteredFeed by derivedStateOf {