From 2d3f03acff5168cbc908c97e17038b5923a98b06 Mon Sep 17 00:00:00 2001 From: Lachlan McKee Date: Fri, 15 Jul 2022 10:27:03 +0100 Subject: [PATCH 01/15] Copied BaseFeature as BaseAsyncFeature --- .../badoo/mvicore/feature/BaseAsyncFeature.kt | 292 ++++++++++++++++++ 1 file changed, 292 insertions(+) create mode 100644 mvicore/src/main/java/com/badoo/mvicore/feature/BaseAsyncFeature.kt diff --git a/mvicore/src/main/java/com/badoo/mvicore/feature/BaseAsyncFeature.kt b/mvicore/src/main/java/com/badoo/mvicore/feature/BaseAsyncFeature.kt new file mode 100644 index 00000000..90bd2579 --- /dev/null +++ b/mvicore/src/main/java/com/badoo/mvicore/feature/BaseAsyncFeature.kt @@ -0,0 +1,292 @@ +package com.badoo.mvicore.feature + +import com.badoo.binder.middleware.wrapWithMiddleware +import com.badoo.mvicore.element.Actor +import com.badoo.mvicore.element.Bootstrapper +import com.badoo.mvicore.element.NewsPublisher +import com.badoo.mvicore.element.PostProcessor +import com.badoo.mvicore.element.Reducer +import com.badoo.mvicore.element.WishToAction +import com.badoo.mvicore.extension.SameThreadVerifier +import com.badoo.mvicore.extension.asConsumer +import com.badoo.mvicore.extension.observeOnNullable +import com.badoo.mvicore.extension.serializeIfNotNull +import com.badoo.mvicore.extension.subscribeOnNullable +import io.reactivex.Observable +import io.reactivex.ObservableSource +import io.reactivex.Observer +import io.reactivex.Scheduler +import io.reactivex.disposables.CompositeDisposable +import io.reactivex.disposables.Disposable +import io.reactivex.functions.Consumer +import io.reactivex.rxkotlin.plusAssign +import io.reactivex.subjects.BehaviorSubject +import io.reactivex.subjects.PublishSubject +import io.reactivex.subjects.Subject +import java.util.concurrent.atomic.AtomicReference + +open class BaseAsyncFeature( + initialState: State, + bootstrapper: Bootstrapper? = null, + private val wishToAction: WishToAction, + actor: Actor, + reducer: Reducer, + postProcessor: PostProcessor? = null, + newsPublisher: NewsPublisher? = null, + private val schedulers: FeatureSchedulers? = null +) : AsyncFeature { + + private val threadVerifier by lazy { SameThreadVerifier() } + private val actionSubject = PublishSubject.create().serializeIfNotNull(schedulers) + // store last state to make best effort to return it in getState() + private val lastState = AtomicReference(initialState) + private val stateSubject = BehaviorSubject.createDefault(initialState).serializeIfNotNull(schedulers) + private val newsSubject = PublishSubject.create().serializeIfNotNull(schedulers) + private val disposables = CompositeDisposable() + private val postProcessorWrapper = postProcessor?.let { + PostProcessorWrapper( + postProcessor, + actionSubject + ).wrapWithMiddleware(wrapperOf = postProcessor) + } + + private val newsPublisherWrapper = newsPublisher?.let { + NewsPublisherWrapper( + newsPublisher, + newsSubject + ).wrapWithMiddleware(wrapperOf = newsPublisher) + } + + private val reducerWrapper = ReducerWrapper( + reducer, + stateSubject, + postProcessorWrapper, + newsPublisherWrapper + ).wrapWithMiddleware(wrapperOf = reducer) + + private val actorWrapper = ActorWrapper( + disposables, + actor, + stateSubject, + reducerWrapper, + schedulers?.featureScheduler, + lazy { threadVerifier } // pass as lazy to not initialize here + ).wrapWithMiddleware(wrapperOf = actor) + + init { + if (schedulers?.featureScheduler == null) threadVerifier + + disposables += stateSubject.subscribe { lastState.set(it) } + disposables += actorWrapper + disposables += reducerWrapper + disposables += postProcessorWrapper + disposables += newsPublisherWrapper + disposables += + actionSubject + .observeOnNullable(schedulers?.featureScheduler) + .subscribe { invokeActor(state, it) } + + if (bootstrapper != null) { + actionSubject + .asConsumer() + .wrapWithMiddleware( + wrapperOf = bootstrapper, + postfix = "output" + ).also { output -> + disposables += output + disposables += + Observable + .defer { bootstrapper() } + .subscribeOnNullable(schedulers?.featureScheduler) + .observeOnNullable(schedulers?.featureScheduler) + .subscribe { output.accept(it) } + } + } + } + + override val backgroundStates: Observable + get() = stateSubject + + override val backgroundNews: Observable + get() = newsSubject + + override val state: State + get() = lastState.get() + + override val news: ObservableSource + get() = newsSubject.observeOnNullable(schedulers?.observationScheduler) + + override fun subscribe(observer: Observer) { + stateSubject + .observeOnNullable(schedulers?.observationScheduler) + .subscribe(observer) + } + + override fun accept(wish: Wish) { + val action = wishToAction.invoke(wish) + actionSubject.onNext(action) + } + + override fun dispose() { + disposables.dispose() + } + + override fun isDisposed(): Boolean = + disposables.isDisposed + + private fun invokeActor(state: State, action: Action) { + if (isDisposed) return + + if (actorWrapper is ActorWrapper) { + // there's no middleware around it, so we can optimise here by not creating any extra objects + actorWrapper.processAction(state, action) + + } else { + // there are middlewares around it, and we must treat it as Consumer + actorWrapper.accept(Pair(state, action)) + } + } + + private operator fun CompositeDisposable.plusAssign(any: Any?) { + if (any is Disposable) add(any) + } + + private class ActorWrapper( + private val disposables: CompositeDisposable, + private val actor: Actor, + private val stateSubject: Subject, + private val reducerWrapper: Consumer>, + private val featureScheduler: Scheduler?, + private val threadVerifier: Lazy + ) : Consumer> { + + // record-playback entry point + override fun accept(t: Pair) { + val (state, action) = t + processAction(state, action) + } + + fun processAction(state: State, action: Action) { + if (disposables.isDisposed) return + + var disposable: Disposable? = null + disposable = + actor + .invoke(state, action) + .observeOnNullable(featureScheduler) + .doAfterTerminate { + // Remove disposables manually because CompositeDisposable does not do it automatically producing memory leaks + // Check for null as it might be disposed instantly + disposable?.let(disposables::remove) + } + .subscribe { effect -> invokeReducer(action, effect) } + // Disposable might be already disposed in case of no scheduler + Observable.just + if (!disposable.isDisposed) disposables += disposable + } + + private fun invokeReducer(action: Action, effect: Effect) { + if (disposables.isDisposed) return + val state = + if (stateSubject is BehaviorSubject) { + requireNotNull(stateSubject.value) + } else { + // if serialized wait for async processes to complete and get the actual value + // blockingFirst is happening on the featureScheduler in this case + stateSubject.blockingFirst() + } + + threadVerifier.value.verify() + if (reducerWrapper is ReducerWrapper) { + // there's no middleware around it, so we can optimise here by not creating any extra objects + reducerWrapper.processEffect(state, action, effect) + + } else { + // there are middlewares around it, and we must treat it as Consumer + reducerWrapper.accept(Triple(state, action, effect)) + } + } + } + + private class ReducerWrapper( + private val reducer: Reducer, + private val states: Subject, + private val postProcessorWrapper: Consumer>?, + private val newsPublisherWrapper: Consumer>? + ) : Consumer> { + + // record-playback entry point + override fun accept(t: Triple) { + val (state, action, effect) = t + processEffect(state, action, effect) + } + + fun processEffect(state: State, action: Action, effect: Effect) { + val newState = reducer.invoke(state, effect) + states.onNext(newState) + invokePostProcessor(action, effect, newState) + invokeNewsPublisher(action, effect, newState) + } + + private fun invokePostProcessor(action: Action, effect: Effect, state: State) { + postProcessorWrapper?.let { + if (postProcessorWrapper is PostProcessorWrapper) { + // there's no middleware around it, so we can optimise here by not creating any extra objects + postProcessorWrapper.postProcess(action, effect, state) + + } else { + // there are middlewares around it, and we must treat it as Consumer + postProcessorWrapper.accept(Triple(action, effect, state)) + } + } + } + + private fun invokeNewsPublisher(action: Action, effect: Effect, state: State) { + newsPublisherWrapper?.let { + if (newsPublisherWrapper is NewsPublisherWrapper) { + // there's no middleware around it, so we can optimise here by not creating any extra objects + newsPublisherWrapper.publishNews(action, effect, state) + + } else { + // there are middlewares around it, and we must treat it as Consumer + newsPublisherWrapper.accept(Triple(action, effect, state)) + } + } + } + } + + private class PostProcessorWrapper( + private val postProcessor: PostProcessor, + private val actions: Subject + ) : Consumer> { + + // record-playback entry point + override fun accept(t: Triple) { + val (action, effect, state) = t + postProcess(action, effect, state) + } + + fun postProcess(action: Action, effect: Effect, state: State) { + postProcessor.invoke(action, effect, state)?.let { + actions.onNext(it) + } + } + } + + private class NewsPublisherWrapper( + private val newsPublisher: NewsPublisher, + private val news: Subject + ) : Consumer> { + + // record-playback entry point + override fun accept(t: Triple) { + val (action, effect, state) = t + publishNews(action, effect, state) + } + + fun publishNews(action: Action, effect: Effect, state: State) { + newsPublisher.invoke(action, effect, state)?.let { + news.onNext(it) + } + } + } +} From b8cbe79a8efc9610bfc9869ea67e33a53cbc3514 Mon Sep 17 00:00:00 2001 From: Lachlan McKee Date: Fri, 15 Jul 2022 10:35:24 +0100 Subject: [PATCH 02/15] Reverted BaseFeature behaviour and removed BaseAsync nullability --- .../java/com/badoo/mvicore/extension/Rx.kt | 12 --- .../mvicore/feature/ActorReducerFeature.kt | 6 +- .../badoo/mvicore/feature/BaseAsyncFeature.kt | 27 +++--- .../com/badoo/mvicore/feature/BaseFeature.kt | 92 ++++++------------- .../com/badoo/mvicore/feature/MemoFeature.kt | 6 +- .../badoo/mvicore/feature/ReducerFeature.kt | 6 +- .../mvicore/feature/AsyncBaseFeatureTest.kt | 23 ++--- 7 files changed, 51 insertions(+), 121 deletions(-) diff --git a/mvicore/src/main/java/com/badoo/mvicore/extension/Rx.kt b/mvicore/src/main/java/com/badoo/mvicore/extension/Rx.kt index 100b2345..6d634369 100644 --- a/mvicore/src/main/java/com/badoo/mvicore/extension/Rx.kt +++ b/mvicore/src/main/java/com/badoo/mvicore/extension/Rx.kt @@ -1,18 +1,6 @@ package com.badoo.mvicore.extension -import io.reactivex.Observable import io.reactivex.Observer -import io.reactivex.Scheduler import io.reactivex.functions.Consumer -import io.reactivex.subjects.Subject fun Observer.asConsumer() = Consumer { onNext(it) } - -internal fun Observable.observeOnNullable(scheduler: Scheduler?): Observable = - if (scheduler != null) observeOn(scheduler) else this - -internal fun Observable.subscribeOnNullable(scheduler: Scheduler?): Observable = - if (scheduler != null) subscribeOn(scheduler) else this - -internal fun Subject.serializeIfNotNull(param: Any?): Subject = - if (param != null) toSerialized() else this diff --git a/mvicore/src/main/java/com/badoo/mvicore/feature/ActorReducerFeature.kt b/mvicore/src/main/java/com/badoo/mvicore/feature/ActorReducerFeature.kt index 9193d3aa..9a798b0c 100644 --- a/mvicore/src/main/java/com/badoo/mvicore/feature/ActorReducerFeature.kt +++ b/mvicore/src/main/java/com/badoo/mvicore/feature/ActorReducerFeature.kt @@ -10,14 +10,12 @@ open class ActorReducerFeature? = null, actor: Actor, reducer: Reducer, - newsPublisher: NewsPublisher? = null, - schedulers: FeatureSchedulers? = null + newsPublisher: NewsPublisher? = null ) : BaseFeature( initialState = initialState, bootstrapper = bootstrapper, wishToAction = { wish -> wish }, actor = actor, reducer = reducer, - newsPublisher = newsPublisher, - schedulers = schedulers + newsPublisher = newsPublisher ) diff --git a/mvicore/src/main/java/com/badoo/mvicore/feature/BaseAsyncFeature.kt b/mvicore/src/main/java/com/badoo/mvicore/feature/BaseAsyncFeature.kt index 90bd2579..fa9b2408 100644 --- a/mvicore/src/main/java/com/badoo/mvicore/feature/BaseAsyncFeature.kt +++ b/mvicore/src/main/java/com/badoo/mvicore/feature/BaseAsyncFeature.kt @@ -9,9 +9,6 @@ import com.badoo.mvicore.element.Reducer import com.badoo.mvicore.element.WishToAction import com.badoo.mvicore.extension.SameThreadVerifier import com.badoo.mvicore.extension.asConsumer -import com.badoo.mvicore.extension.observeOnNullable -import com.badoo.mvicore.extension.serializeIfNotNull -import com.badoo.mvicore.extension.subscribeOnNullable import io.reactivex.Observable import io.reactivex.ObservableSource import io.reactivex.Observer @@ -33,15 +30,15 @@ open class BaseAsyncFeature, postProcessor: PostProcessor? = null, newsPublisher: NewsPublisher? = null, - private val schedulers: FeatureSchedulers? = null + private val schedulers: FeatureSchedulers ) : AsyncFeature { private val threadVerifier by lazy { SameThreadVerifier() } - private val actionSubject = PublishSubject.create().serializeIfNotNull(schedulers) + private val actionSubject = PublishSubject.create().toSerialized() // store last state to make best effort to return it in getState() private val lastState = AtomicReference(initialState) - private val stateSubject = BehaviorSubject.createDefault(initialState).serializeIfNotNull(schedulers) - private val newsSubject = PublishSubject.create().serializeIfNotNull(schedulers) + private val stateSubject = BehaviorSubject.createDefault(initialState).toSerialized() + private val newsSubject = PublishSubject.create().toSerialized() private val disposables = CompositeDisposable() private val postProcessorWrapper = postProcessor?.let { PostProcessorWrapper( @@ -69,13 +66,11 @@ open class BaseAsyncFeature - get() = newsSubject.observeOnNullable(schedulers?.observationScheduler) + get() = newsSubject.observeOn(schedulers.observationScheduler) override fun subscribe(observer: Observer) { stateSubject - .observeOnNullable(schedulers?.observationScheduler) + .observeOn(schedulers.observationScheduler) .subscribe(observer) } @@ -173,7 +168,7 @@ open class BaseAsyncFeature( initialState: State, @@ -32,16 +27,13 @@ open class BaseFeature, reducer: Reducer, postProcessor: PostProcessor? = null, - newsPublisher: NewsPublisher? = null, - private val schedulers: FeatureSchedulers? = null -) : AsyncFeature { - - private val threadVerifier by lazy { SameThreadVerifier() } - private val actionSubject = PublishSubject.create().serializeIfNotNull(schedulers) - // store last state to make best effort to return it in getState() - private val lastState = AtomicReference(initialState) - private val stateSubject = BehaviorSubject.createDefault(initialState).serializeIfNotNull(schedulers) - private val newsSubject = PublishSubject.create().serializeIfNotNull(schedulers) + newsPublisher: NewsPublisher? = null +) : Feature { + + private val threadVerifier = SameThreadVerifier() + private val actionSubject = PublishSubject.create() + private val stateSubject = BehaviorSubject.createDefault(initialState) + private val newsSubject = PublishSubject.create() private val disposables = CompositeDisposable() private val postProcessorWrapper = postProcessor?.let { PostProcessorWrapper( @@ -65,26 +57,21 @@ open class BaseFeature - get() = stateSubject - - override val backgroundNews: Observable - get() = newsSubject - override val state: State - get() = lastState.get() + get() = stateSubject.value!! override val news: ObservableSource - get() = newsSubject.observeOnNullable(schedulers?.observationScheduler) + get() = newsSubject + override fun subscribe(observer: Observer) { - stateSubject - .observeOnNullable(schedulers?.observationScheduler) - .subscribe(observer) + stateSubject.subscribe(observer) } override fun accept(wish: Wish) { @@ -152,12 +130,11 @@ open class BaseFeature( + private val threadVerifier: SameThreadVerifier, private val disposables: CompositeDisposable, private val actor: Actor, - private val stateSubject: Subject, - private val reducerWrapper: Consumer>, - private val featureScheduler: Scheduler?, - private val threadVerifier: Lazy + private val stateSubject: BehaviorSubject, + private val reducerWrapper: Consumer> ) : Consumer> { // record-playback entry point @@ -169,33 +146,18 @@ open class BaseFeature invokeReducer(action, effect) } - // Disposable might be already disposed in case of no scheduler + Observable.just - if (!disposable.isDisposed) disposables += disposable + disposables += actor + .invoke(state, action) + .doOnNext { effect -> + invokeReducer(stateSubject.value!!, action, effect) + } + .subscribe() } - private fun invokeReducer(action: Action, effect: Effect) { + private fun invokeReducer(state: State, action: Action, effect: Effect) { if (disposables.isDisposed) return - val state = - if (stateSubject is BehaviorSubject) { - requireNotNull(stateSubject.value) - } else { - // if serialized wait for async processes to complete and get the actual value - // blockingFirst is happening on the featureScheduler in this case - stateSubject.blockingFirst() - } - threadVerifier.value.verify() + threadVerifier.verify() if (reducerWrapper is ReducerWrapper) { // there's no middleware around it, so we can optimise here by not creating any extra objects reducerWrapper.processEffect(state, action, effect) diff --git a/mvicore/src/main/java/com/badoo/mvicore/feature/MemoFeature.kt b/mvicore/src/main/java/com/badoo/mvicore/feature/MemoFeature.kt index 1516fff9..5edbbfbd 100644 --- a/mvicore/src/main/java/com/badoo/mvicore/feature/MemoFeature.kt +++ b/mvicore/src/main/java/com/badoo/mvicore/feature/MemoFeature.kt @@ -1,10 +1,8 @@ package com.badoo.mvicore.feature open class MemoFeature( - initialState: State, - schedulers: FeatureSchedulers? = null + initialState: State ) : Feature by ReducerFeature( initialState = initialState, - reducer = { _, effect -> effect }, - schedulers = schedulers + reducer = { _, effect -> effect } ) diff --git a/mvicore/src/main/java/com/badoo/mvicore/feature/ReducerFeature.kt b/mvicore/src/main/java/com/badoo/mvicore/feature/ReducerFeature.kt index db2b5571..2126d9aa 100644 --- a/mvicore/src/main/java/com/badoo/mvicore/feature/ReducerFeature.kt +++ b/mvicore/src/main/java/com/badoo/mvicore/feature/ReducerFeature.kt @@ -12,16 +12,14 @@ open class ReducerFeature( initialState: State, reducer: Reducer, bootstrapper: Bootstrapper? = null, - newsPublisher: SimpleNewsPublisher? = null, - schedulers: FeatureSchedulers? = null + newsPublisher: SimpleNewsPublisher? = null ) : BaseFeature( initialState = initialState, bootstrapper = bootstrapper, wishToAction = { wish -> wish }, actor = BypassActor(), reducer = reducer, - newsPublisher = newsPublisher, - schedulers = schedulers + newsPublisher = newsPublisher ) { class BypassActor : Actor, NonWrappable { override fun invoke(state: State, wish: Wish): Observable = diff --git a/mvicore/src/test/java/com/badoo/mvicore/feature/AsyncBaseFeatureTest.kt b/mvicore/src/test/java/com/badoo/mvicore/feature/AsyncBaseFeatureTest.kt index cb685e83..ee383240 100644 --- a/mvicore/src/test/java/com/badoo/mvicore/feature/AsyncBaseFeatureTest.kt +++ b/mvicore/src/test/java/com/badoo/mvicore/feature/AsyncBaseFeatureTest.kt @@ -51,11 +51,6 @@ class AsyncBaseFeatureTest { disposable.clear() } - @Test - fun `allows creation without schedulers specification`() { - feature = testFeature(featureScheduler = null, observationScheduler = null) - } - @Test fun `allows creation with both schedulers`() { feature = testFeature(featureScheduler = Schedulers.trampoline(), observationScheduler = Schedulers.trampoline()) @@ -176,15 +171,15 @@ class AsyncBaseFeatureTest { } private fun testFeature( - featureScheduler: Scheduler? = this.featureScheduler, - observationScheduler: Scheduler? = this.observationScheduler, + featureScheduler: Scheduler = this.featureScheduler, + observationScheduler: Scheduler = this.observationScheduler, bootstrapper: Bootstrapper? = { Observable.just(Action()).observeOn(Schedulers.single()) }, wishToAction: WishToAction = { Action() }, actor: Actor = { _, _ -> Observable.just(Effect()).observeOn(Schedulers.single()) }, reducer: Reducer = { _, _ -> State() }, postProcessor: PostProcessor = { _, _, _ -> null }, newsPublisher: NewsPublisher = { _, _, _ -> News() } - ) = BaseFeature( + ) = BaseAsyncFeature( initialState = State(), bootstrapper = bootstrapper, wishToAction = wishToAction, @@ -192,14 +187,10 @@ class AsyncBaseFeatureTest { reducer = reducer, newsPublisher = newsPublisher, postProcessor = postProcessor, - schedulers = if (featureScheduler != null && observationScheduler != null) { - FeatureSchedulers( - featureScheduler = featureScheduler, - observationScheduler = observationScheduler - ) - } else { - null - } + schedulers = FeatureSchedulers( + featureScheduler = featureScheduler, + observationScheduler = observationScheduler + ) ) private fun ObservableSource.wrap() = From 0bc241b79c6d3ea0d214cea837bb10cfd14e87d8 Mon Sep 17 00:00:00 2001 From: Lachlan McKee Date: Fri, 15 Jul 2022 10:46:45 +0100 Subject: [PATCH 03/15] Moved FeatureSchedulers into AsyncBaseFeature --- .../com/badoo/mvicore/feature/BaseAsyncFeature.kt | 9 +++++++++ .../com/badoo/mvicore/feature/FeatureSchedulers.kt | 12 ------------ .../badoo/mvicore/feature/AsyncBaseFeatureTest.kt | 1 + 3 files changed, 10 insertions(+), 12 deletions(-) delete mode 100644 mvicore/src/main/java/com/badoo/mvicore/feature/FeatureSchedulers.kt diff --git a/mvicore/src/main/java/com/badoo/mvicore/feature/BaseAsyncFeature.kt b/mvicore/src/main/java/com/badoo/mvicore/feature/BaseAsyncFeature.kt index fa9b2408..918dec2a 100644 --- a/mvicore/src/main/java/com/badoo/mvicore/feature/BaseAsyncFeature.kt +++ b/mvicore/src/main/java/com/badoo/mvicore/feature/BaseAsyncFeature.kt @@ -284,4 +284,13 @@ open class BaseAsyncFeature Date: Sat, 16 Jul 2022 12:34:40 +0100 Subject: [PATCH 04/15] Updated BaseFeature to automatically switch to provided feature scheduler --- mvicore-android/build.gradle | 1 + .../AndroidMainThreadFeatureScheduler.kt | 21 ++ .../mvicore/feature/ActorReducerFeature.kt | 6 +- .../com/badoo/mvicore/feature/BaseFeature.kt | 85 +++-- .../com/badoo/mvicore/feature/MemoFeature.kt | 6 +- .../badoo/mvicore/feature/ReducerFeature.kt | 6 +- .../test/java/com/badoo/mvicore/TestHelper.kt | 19 +- .../mvicore/feature/AsyncBaseFeatureTest.kt | 3 +- .../feature/BaseFeatureWithSchedulerTest.kt | 322 ++++++++++++++++++ ....kt => BaseFeatureWithoutSchedulerTest.kt} | 5 +- 10 files changed, 439 insertions(+), 35 deletions(-) create mode 100644 mvicore-android/src/main/java/com/badoo/mvicore/android/AndroidMainThreadFeatureScheduler.kt create mode 100644 mvicore/src/test/java/com/badoo/mvicore/feature/BaseFeatureWithSchedulerTest.kt rename mvicore/src/test/java/com/badoo/mvicore/feature/{BaseFeatureTest.kt => BaseFeatureWithoutSchedulerTest.kt} (98%) diff --git a/mvicore-android/build.gradle b/mvicore-android/build.gradle index 874b234a..5eb3104e 100644 --- a/mvicore-android/build.gradle +++ b/mvicore-android/build.gradle @@ -41,6 +41,7 @@ dependencies { implementation deps('io.reactivex.rxjava2:rxjava') implementation deps('io.reactivex.rxjava2:rxkotlin') + implementation deps('io.reactivex.rxjava2:rxandroid') testImplementation deps('junit:junit') testImplementation deps('org.jetbrains.kotlin:kotlin-test-junit') diff --git a/mvicore-android/src/main/java/com/badoo/mvicore/android/AndroidMainThreadFeatureScheduler.kt b/mvicore-android/src/main/java/com/badoo/mvicore/android/AndroidMainThreadFeatureScheduler.kt new file mode 100644 index 00000000..bb27b998 --- /dev/null +++ b/mvicore-android/src/main/java/com/badoo/mvicore/android/AndroidMainThreadFeatureScheduler.kt @@ -0,0 +1,21 @@ +package com.badoo.mvicore.android + +import android.os.Looper +import com.badoo.mvicore.feature.BaseFeature +import io.reactivex.Scheduler +import io.reactivex.android.schedulers.AndroidSchedulers + +/** + * A feature scheduler that ensures that MVICore feature only manipulates state on the Android + * main thread. + * + * It also uses the 'isOnFeatureThread' field to avoid observing on the main thread if it is already + * the current thread. + */ +object AndroidMainThreadFeatureScheduler: BaseFeature.FeatureScheduler { + override val scheduler: Scheduler + get() = AndroidSchedulers.mainThread() + + override val isOnFeatureThread: Boolean + get() = Looper.myLooper() == Looper.getMainLooper() +} diff --git a/mvicore/src/main/java/com/badoo/mvicore/feature/ActorReducerFeature.kt b/mvicore/src/main/java/com/badoo/mvicore/feature/ActorReducerFeature.kt index 9a798b0c..612825cf 100644 --- a/mvicore/src/main/java/com/badoo/mvicore/feature/ActorReducerFeature.kt +++ b/mvicore/src/main/java/com/badoo/mvicore/feature/ActorReducerFeature.kt @@ -10,12 +10,14 @@ open class ActorReducerFeature? = null, actor: Actor, reducer: Reducer, - newsPublisher: NewsPublisher? = null + newsPublisher: NewsPublisher? = null, + featureScheduler: FeatureScheduler? = null ) : BaseFeature( initialState = initialState, bootstrapper = bootstrapper, wishToAction = { wish -> wish }, actor = actor, reducer = reducer, - newsPublisher = newsPublisher + newsPublisher = newsPublisher, + featureScheduler = featureScheduler ) diff --git a/mvicore/src/main/java/com/badoo/mvicore/feature/BaseFeature.kt b/mvicore/src/main/java/com/badoo/mvicore/feature/BaseFeature.kt index 590e7262..cd21e669 100644 --- a/mvicore/src/main/java/com/badoo/mvicore/feature/BaseFeature.kt +++ b/mvicore/src/main/java/com/badoo/mvicore/feature/BaseFeature.kt @@ -12,6 +12,7 @@ import com.badoo.mvicore.extension.asConsumer import io.reactivex.Observable import io.reactivex.ObservableSource import io.reactivex.Observer +import io.reactivex.Scheduler import io.reactivex.disposables.CompositeDisposable import io.reactivex.disposables.Disposable import io.reactivex.functions.Consumer @@ -27,11 +28,12 @@ open class BaseFeature, reducer: Reducer, postProcessor: PostProcessor? = null, - newsPublisher: NewsPublisher? = null + newsPublisher: NewsPublisher? = null, + private val featureScheduler: FeatureScheduler? = null ) : Feature { - private val threadVerifier = SameThreadVerifier() - private val actionSubject = PublishSubject.create() + private val threadVerifier = if (featureScheduler == null) SameThreadVerifier() else null + private val actionSubject = PublishSubject.create().toSerialized() private val stateSubject = BehaviorSubject.createDefault(initialState) private val newsSubject = PublishSubject.create() private val disposables = CompositeDisposable() @@ -61,7 +63,8 @@ open class BaseFeature - disposables += output - disposables += + setupBootstrapper(bootstrapper) + } + } + + private fun setupBootstrapper(bootstrapper: Bootstrapper) { + actionSubject + .asConsumer() + .wrapWithMiddleware( + wrapperOf = bootstrapper, + postfix = "output" + ).also { output -> + disposables += output + disposables += + if (featureScheduler == null || featureScheduler.isOnFeatureThread) { + bootstrapper.invoke().subscribe { + output.accept(it) + } + } else { Observable .defer { bootstrapper() } - .subscribe { output.accept(it) } - } - } + .subscribeOn(featureScheduler.scheduler) + .subscribe { + // As the action subject is serialized, it doesn't matter if we + // are no longer on the feature scheduler thread. + output.accept(it) + } + } + } } override val state: State @@ -95,7 +113,6 @@ open class BaseFeature get() = newsSubject - override fun subscribe(observer: Observer) { stateSubject.subscribe(observer) } @@ -130,11 +147,12 @@ open class BaseFeature( - private val threadVerifier: SameThreadVerifier, + private val threadVerifier: SameThreadVerifier?, private val disposables: CompositeDisposable, private val actor: Actor, private val stateSubject: BehaviorSubject, - private val reducerWrapper: Consumer> + private val reducerWrapper: Consumer>, + private val featureScheduler: FeatureScheduler? ) : Consumer> { // record-playback entry point @@ -149,7 +167,9 @@ open class BaseFeature - invokeReducer(stateSubject.value!!, action, effect) + featureScheduler.runOnFeatureThread { + invokeReducer(stateSubject.value!!, action, effect) + } } .subscribe() } @@ -157,7 +177,7 @@ open class BaseFeature Unit) { + if (this == null || isOnFeatureThread) { + func() + } else { + scheduler.scheduleDirect { func() } + } + } + } } diff --git a/mvicore/src/main/java/com/badoo/mvicore/feature/MemoFeature.kt b/mvicore/src/main/java/com/badoo/mvicore/feature/MemoFeature.kt index 5edbbfbd..32bdfd56 100644 --- a/mvicore/src/main/java/com/badoo/mvicore/feature/MemoFeature.kt +++ b/mvicore/src/main/java/com/badoo/mvicore/feature/MemoFeature.kt @@ -1,8 +1,10 @@ package com.badoo.mvicore.feature open class MemoFeature( - initialState: State + initialState: State, + featureScheduler: BaseFeature.FeatureScheduler? = null ) : Feature by ReducerFeature( initialState = initialState, - reducer = { _, effect -> effect } + reducer = { _, effect -> effect }, + featureScheduler = featureScheduler ) diff --git a/mvicore/src/main/java/com/badoo/mvicore/feature/ReducerFeature.kt b/mvicore/src/main/java/com/badoo/mvicore/feature/ReducerFeature.kt index 2126d9aa..38d8538b 100644 --- a/mvicore/src/main/java/com/badoo/mvicore/feature/ReducerFeature.kt +++ b/mvicore/src/main/java/com/badoo/mvicore/feature/ReducerFeature.kt @@ -12,14 +12,16 @@ open class ReducerFeature( initialState: State, reducer: Reducer, bootstrapper: Bootstrapper? = null, - newsPublisher: SimpleNewsPublisher? = null + newsPublisher: SimpleNewsPublisher? = null, + featureScheduler: FeatureScheduler? = null ) : BaseFeature( initialState = initialState, bootstrapper = bootstrapper, wishToAction = { wish -> wish }, actor = BypassActor(), reducer = reducer, - newsPublisher = newsPublisher + newsPublisher = newsPublisher, + featureScheduler = featureScheduler ) { class BypassActor : Actor, NonWrappable { override fun invoke(state: State, wish: Wish): Observable = diff --git a/mvicore/src/test/java/com/badoo/mvicore/TestHelper.kt b/mvicore/src/test/java/com/badoo/mvicore/TestHelper.kt index c90bbcb3..050961fc 100644 --- a/mvicore/src/test/java/com/badoo/mvicore/TestHelper.kt +++ b/mvicore/src/test/java/com/badoo/mvicore/TestHelper.kt @@ -23,9 +23,11 @@ import com.badoo.mvicore.TestHelper.TestWish.MaybeFulfillable import com.badoo.mvicore.TestHelper.TestWish.TranslatesTo3Effects import com.badoo.mvicore.TestHelper.TestWish.Unfulfillable import com.badoo.mvicore.element.Actor +import com.badoo.mvicore.element.Bootstrapper import com.badoo.mvicore.element.NewsPublisher import com.badoo.mvicore.element.Reducer import io.reactivex.Observable +import io.reactivex.Observable.empty import io.reactivex.Observable.just import io.reactivex.Scheduler import java.util.concurrent.TimeUnit @@ -52,6 +54,10 @@ class TestHelper { val loading: Boolean = false ) + class TestEmptyBootstrapper : Bootstrapper { + override fun invoke(): Observable = empty() + } + sealed class TestWish { object Unfulfillable : TestWish() object MaybeFulfillable : TestWish() @@ -134,12 +140,16 @@ class TestHelper { ) } - class TestReducer : Reducer { - override fun invoke(state: TestState, effect: TestEffect): TestState = - when (effect) { + class TestReducer(private val invocationCallback: () -> Unit = {}) : Reducer { + override fun invoke(state: TestState, effect: TestEffect): TestState { + invocationCallback() + return when (effect) { is StartedAsync -> state.copy(loading = true) is InstantEffect -> state.copy(counter = state.counter + effect.amount) - is FinishedAsync -> state.copy(counter = state.counter + effect.amount, loading = false) + is FinishedAsync -> state.copy( + counter = state.counter + effect.amount, + loading = false + ) is ConditionalThingHappened -> state.copy(counter = state.counter * effect.multiplier) MultipleEffect1 -> state MultipleEffect2 -> state @@ -149,6 +159,7 @@ class TestHelper { LoopbackEffect2 -> loopBackState2 LoopbackEffect3 -> loopBackState3 } + } } class TestNewsPublisher : NewsPublisher { diff --git a/mvicore/src/test/java/com/badoo/mvicore/feature/AsyncBaseFeatureTest.kt b/mvicore/src/test/java/com/badoo/mvicore/feature/AsyncBaseFeatureTest.kt index 2094e341..c6b5ac7a 100644 --- a/mvicore/src/test/java/com/badoo/mvicore/feature/AsyncBaseFeatureTest.kt +++ b/mvicore/src/test/java/com/badoo/mvicore/feature/AsyncBaseFeatureTest.kt @@ -23,8 +23,7 @@ import java.util.concurrent.Executors import java.util.concurrent.TimeUnit /** - * Tests async functionality of [BaseFeature]. - * Basic tests are inside [BaseFeatureTest]. + * Tests async functionality of [BaseAsyncFeature]. */ class AsyncBaseFeatureTest { diff --git a/mvicore/src/test/java/com/badoo/mvicore/feature/BaseFeatureWithSchedulerTest.kt b/mvicore/src/test/java/com/badoo/mvicore/feature/BaseFeatureWithSchedulerTest.kt new file mode 100644 index 00000000..1642b14a --- /dev/null +++ b/mvicore/src/test/java/com/badoo/mvicore/feature/BaseFeatureWithSchedulerTest.kt @@ -0,0 +1,322 @@ +package com.badoo.mvicore.feature + +import com.badoo.mvicore.TestHelper +import com.badoo.mvicore.TestHelper.Companion.conditionalMultiplier +import com.badoo.mvicore.TestHelper.Companion.initialCounter +import com.badoo.mvicore.TestHelper.Companion.initialLoading +import com.badoo.mvicore.TestHelper.Companion.instantFulfillAmount1 +import com.badoo.mvicore.TestHelper.TestNews +import com.badoo.mvicore.TestHelper.TestState +import com.badoo.mvicore.TestHelper.TestWish +import com.badoo.mvicore.TestHelper.TestWish.FulfillableAsync +import com.badoo.mvicore.TestHelper.TestWish.FulfillableInstantly1 +import com.badoo.mvicore.TestHelper.TestWish.LoopbackWish1 +import com.badoo.mvicore.TestHelper.TestWish.LoopbackWish2 +import com.badoo.mvicore.TestHelper.TestWish.LoopbackWish3 +import com.badoo.mvicore.TestHelper.TestWish.LoopbackWishInitial +import com.badoo.mvicore.TestHelper.TestWish.MaybeFulfillable +import com.badoo.mvicore.TestHelper.TestWish.TranslatesTo3Effects +import com.badoo.mvicore.TestHelper.TestWish.Unfulfillable +import com.badoo.mvicore.extension.SameThreadVerifier +import com.badoo.mvicore.onNextEvents +import io.reactivex.Observable +import io.reactivex.Scheduler +import io.reactivex.internal.schedulers.RxThreadFactory +import io.reactivex.observers.TestObserver +import io.reactivex.plugins.RxJavaPlugins +import io.reactivex.schedulers.Schedulers +import io.reactivex.schedulers.TestScheduler +import io.reactivex.subjects.PublishSubject +import org.junit.Before +import org.junit.Test +import org.mockito.MockitoAnnotations +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import kotlin.test.assertEquals +import kotlin.test.fail + +class BaseFeatureWithSchedulerTest { + private lateinit var feature: Feature + private lateinit var states: TestObserver + private lateinit var newsSubject: PublishSubject + private lateinit var actorInvocationLog: PublishSubject> + private lateinit var actorInvocationLogTest: TestObserver> + private lateinit var actorScheduler: Scheduler + private val featureScheduler = TestThreadFeatureScheduler() + + @Before + fun prepare() { + MockitoAnnotations.initMocks(this) + SameThreadVerifier.isEnabled = true + + newsSubject = PublishSubject.create() + actorInvocationLog = PublishSubject.create>() + actorInvocationLogTest = actorInvocationLog.test() + actorScheduler = TestScheduler() + } + + private fun initFeature() { + feature = BaseFeature( + initialState = TestState(), + bootstrapper = TestHelper.TestEmptyBootstrapper(), + wishToAction = { wish -> wish }, + actor = TestHelper.TestActor( + { wish, state -> + if (!featureScheduler.isOnFeatureThread) { + fail("Actor was not invoked on the feature thread") + } + actorInvocationLog.onNext(wish to state) + }, + actorScheduler + ), + reducer = TestHelper.TestReducer(invocationCallback = { + if (!featureScheduler.isOnFeatureThread) { + fail("Reducer was not invoked on the feature thread") + } + }), + newsPublisher = TestHelper.TestNewsPublisher(), + featureScheduler = featureScheduler + ) + + val subscription = PublishSubject.create() + states = subscription.test() + feature.subscribe(subscription) + feature.news.subscribe(newsSubject) + } + + private fun initAndObserveFeature(): TestObserver { + initFeature() + return Observable.wrap(feature).test() + } + + @Test + fun `if there are no wishes, feature only emits initial state`() { + initFeature() + assertEquals(1, states.onNextEvents().size) + } + + @Test + fun `emitted initial state is correct`() { + initFeature() + val state: TestState = states.onNextEvents().first() as TestState + assertEquals(initialCounter, state.counter) + assertEquals(initialLoading, state.loading) + } + + @Test + fun `there should be no state emission besides the initial one for unfulfillable wishes`() { + initFeature() + feature.accept(Unfulfillable) + feature.accept(Unfulfillable) + feature.accept(Unfulfillable) + + actorInvocationLogTest.awaitAndAssertCount(3) + + assertEquals(1, states.onNextEvents().size) + } + + @Test + fun `there should be the same amount of states as wishes that translate 1 - 1 to effects plus one for initial state`() { + val testObserver = initAndObserveFeature() + val wishes = listOf( + // all of them are mapped to 1 effect each + FulfillableInstantly1, + FulfillableInstantly1, + FulfillableInstantly1 + ) + + wishes.forEach { feature.accept(it) } + + testObserver.awaitAndAssertCount(1 + wishes.size) + } + + @Test + fun `there should be 3 times as many states as wishes that translate 1 - 3 to effects plus one for initial state`() { + val testObserver = initAndObserveFeature() + val wishes = listOf( + TranslatesTo3Effects, + TranslatesTo3Effects, + TranslatesTo3Effects + ) + + wishes.forEach { feature.accept(it) } + + testObserver.awaitAndAssertCount(1 + wishes.size * 3) + } + + @Test + fun `last state correctly reflects expected changes in simple case`() { + val testObserver = initAndObserveFeature() + val wishes = listOf( + FulfillableInstantly1, + FulfillableInstantly1, + FulfillableInstantly1 + ) + + wishes.forEach { feature.accept(it) } + + testObserver.awaitAndAssertCount(1 + wishes.size) + val state = states.onNextEvents().last() as TestState + assertEquals(initialCounter + wishes.size * instantFulfillAmount1, state.counter) + assertEquals(false, state.loading) + } + + @Test + fun `intermediate state matches expectations in async case`() { + val testObserver = initAndObserveFeature() + val wishes = listOf( + FulfillableAsync(0) + ) + + wishes.forEach { feature.accept(it) } + + testObserver.awaitAndAssertCount(1 + wishes.size) + val state = states.onNextEvents().last() as TestState + assertEquals(true, state.loading) + assertEquals(initialCounter, state.counter) + } + + @Test + fun `final state matches expectations in async case`() { + val testScheduler = TestScheduler() + actorScheduler = testScheduler + val testObserver = initAndObserveFeature() + val mockServerDelayMs: Long = 10 + + val wishes = listOf( + FulfillableAsync(mockServerDelayMs) + ) + + wishes.forEach { feature.accept(it) } + + // Must wait until the loading state has started, otherwise the timer is advanced too soon. + testObserver.awaitAndAssertCount(1 + wishes.size) + testScheduler.advanceTimeBy(mockServerDelayMs, TimeUnit.MILLISECONDS) + + testObserver.awaitAndAssertCount(2 + wishes.size) + val state = states.onNextEvents().last() as TestState + assertEquals(false, state.loading) + assertEquals(initialCounter + TestHelper.delayedFulfillAmount, state.counter) + } + + @Test + fun `the number of state emissions should reflect the number of effects plus one for initial state in complex case`() { + val testObserver = initAndObserveFeature() + val wishes = listOf( + FulfillableInstantly1, // maps to 1 effect + FulfillableInstantly1, // maps to 1 effect + MaybeFulfillable, // maps to 0 in this case + Unfulfillable, // maps to 0 + FulfillableInstantly1, // maps to 1 + FulfillableInstantly1, // maps to 1 + MaybeFulfillable, // maps to 1 in this case + TranslatesTo3Effects // maps to 3 + ) + + wishes.forEach { feature.accept(it) } + + testObserver.awaitAndAssertCount(8 + 1) + } + + @Test + fun `last state correctly reflects expected changes in complex case`() { + val testObserver = initAndObserveFeature() + val wishes = listOf( + FulfillableInstantly1, // should increase +2 (total: 102) + FulfillableInstantly1, // should increase +2 (total: 104) + MaybeFulfillable, // should not do anything in this state, as total of 2 is not divisible by 3 + Unfulfillable, // should not do anything + FulfillableInstantly1, // should increase +2 (total: 106) + FulfillableInstantly1, // should increase +2 (total: 108) + MaybeFulfillable, // as total of 108 is divisible by 3, it should multiply by *10 (total: 1080) + TranslatesTo3Effects // should not affect state + ) + + wishes.forEach { feature.accept(it) } + + testObserver.awaitAndAssertCount(8 + 1) + val state = states.onNextEvents().last() as TestState + assertEquals((initialCounter + 4 * instantFulfillAmount1) * conditionalMultiplier, state.counter) + assertEquals(false, state.loading) + } + + @Test + fun `loopback from news to multiple wishes has access to correct latest state`() { + val testObserver = initAndObserveFeature() + newsSubject.subscribe { + if (it === TestNews.Loopback) { + feature.accept(LoopbackWish2) + feature.accept(LoopbackWish3) + } + } + + feature.accept(LoopbackWishInitial) + feature.accept(LoopbackWish1) + + actorInvocationLogTest.awaitAndAssertCount(4) + assertEquals(LoopbackWish1 to TestHelper.loopBackInitialState, actorInvocationLogTest.onNextEvents()[1]) + assertEquals(LoopbackWish2 to TestHelper.loopBackState1, actorInvocationLogTest.onNextEvents()[2]) + assertEquals(LoopbackWish3 to TestHelper.loopBackState2, actorInvocationLogTest.onNextEvents()[3]) + } + + @Test + fun `if feature created on different thread, feature scheduler accessed once for bootstrapping`() { + initFeature() + + assertEquals(1, featureScheduler.schedulerInvocationCount) + } + + @Test + fun `if feature created on same thread, feature scheduler not be accessed for bootstrapping`() { + val latch = CountDownLatch(1) + featureScheduler.schedulerIncrementingCount.scheduleDirect { + initFeature() + latch.countDown() + } + + latch.await(5, TimeUnit.SECONDS) + assertEquals(0, featureScheduler.schedulerInvocationCount) + } + + @Test + fun `feature scheduler should be accessed 7 times when 3 async wishes invoked`() { + actorScheduler = Schedulers.computation() + + val testObserver = initAndObserveFeature() + + feature.accept(FulfillableAsync(0)) + feature.accept(FulfillableAsync(0)) + feature.accept(FulfillableAsync(0)) + + testObserver.awaitAndAssertCount(7) + + // Bootstrapper (1) is called on test thread and must be moved to feature thread + // Each wish (3) is called on test thread and must be moved to feature thread + // Each effect (3) is async and must be moved to feature thread + assertEquals(7, featureScheduler.schedulerInvocationCount) + } + + private fun TestObserver.awaitAndAssertCount(count: Int) { + awaitCount(count) + assertValueCount(count) + } + + private class TestThreadFeatureScheduler : BaseFeature.FeatureScheduler { + var schedulerInvocationCount: Int = 0 + + val schedulerIncrementingCount by lazy { + RxJavaPlugins + .createSingleScheduler(RxThreadFactory("AsyncTestScheduler", Thread.NORM_PRIORITY, false)) + .apply { start() } + } + + override val scheduler: Scheduler + get() { + schedulerInvocationCount++ + return schedulerIncrementingCount + } + + override val isOnFeatureThread: Boolean + get() = Thread.currentThread().name.startsWith("AsyncTestScheduler") + } +} diff --git a/mvicore/src/test/java/com/badoo/mvicore/feature/BaseFeatureTest.kt b/mvicore/src/test/java/com/badoo/mvicore/feature/BaseFeatureWithoutSchedulerTest.kt similarity index 98% rename from mvicore/src/test/java/com/badoo/mvicore/feature/BaseFeatureTest.kt rename to mvicore/src/test/java/com/badoo/mvicore/feature/BaseFeatureWithoutSchedulerTest.kt index 15fc4417..e9a05642 100644 --- a/mvicore/src/test/java/com/badoo/mvicore/feature/BaseFeatureTest.kt +++ b/mvicore/src/test/java/com/badoo/mvicore/feature/BaseFeatureWithoutSchedulerTest.kt @@ -28,7 +28,7 @@ import org.mockito.MockitoAnnotations import java.util.concurrent.TimeUnit import kotlin.test.assertEquals -class BaseFeatureTest { +class BaseFeatureWithoutSchedulerTest { private lateinit var feature: Feature private lateinit var states: TestObserver private lateinit var newsSubject: PublishSubject @@ -54,7 +54,8 @@ class BaseFeatureTest { actorScheduler ), reducer = TestHelper.TestReducer(), - newsPublisher = TestHelper.TestNewsPublisher() + newsPublisher = TestHelper.TestNewsPublisher(), + featureScheduler = null ) val subscription = PublishSubject.create() From 88b4827ac7a59995b78ea9849229f1e1b932829b Mon Sep 17 00:00:00 2001 From: Lachlan McKee Date: Mon, 18 Jul 2022 11:46:59 +0100 Subject: [PATCH 05/15] Created FeatureSchedulerFactory to allow creation of non-main thread schedulers --- .../feature/FeatureSchedulerFactory.kt | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 mvicore/src/main/java/com/badoo/mvicore/feature/FeatureSchedulerFactory.kt diff --git a/mvicore/src/main/java/com/badoo/mvicore/feature/FeatureSchedulerFactory.kt b/mvicore/src/main/java/com/badoo/mvicore/feature/FeatureSchedulerFactory.kt new file mode 100644 index 00000000..62785850 --- /dev/null +++ b/mvicore/src/main/java/com/badoo/mvicore/feature/FeatureSchedulerFactory.kt @@ -0,0 +1,58 @@ +package com.badoo.mvicore.feature + +import io.reactivex.Scheduler +import io.reactivex.internal.schedulers.RxThreadFactory +import io.reactivex.plugins.RxJavaPlugins +import java.util.concurrent.ThreadFactory + +object FeatureSchedulerFactory { + /** + * Creates a single threaded feature scheduler. + */ + fun create( + threadPrefix: String, + threadPriority: Int = Thread.NORM_PRIORITY + ): BaseFeature.FeatureScheduler { + return object : BaseFeature.FeatureScheduler { + private val singleThreadedThreadFactory by lazy { + ThreadIdInterceptingThreadFactory(threadPrefix, threadPriority) + } + private val lazyScheduler by lazy { + createScheduler(singleThreadedThreadFactory) + } + + override val scheduler: Scheduler + get() = lazyScheduler + + override val isOnFeatureThread: Boolean + get() = Thread.currentThread().id == singleThreadedThreadFactory.getThreadId() + } + } + + private fun createScheduler(threadFactory: ThreadFactory) = + RxJavaPlugins + .createSingleScheduler(threadFactory) + .apply { start() } + + /** + * A thread factory which stores the thread id of the thread created. + * This factory should only create one thread, otherwise it will throw an exception. + */ + private class ThreadIdInterceptingThreadFactory(prefix: String, priority: Int) : ThreadFactory { + private val delegate by lazy { RxThreadFactory(prefix, priority, false) } + private var threadId: Long? = null + + fun getThreadId(): Long = + requireNotNull(threadId) { + "Thread Id was not found. The scheduler may not have created the thread yet" + } + + private fun setThreadId(threadId: Long) { + check(this.threadId == null) { "Multiple threads have been created" } + this.threadId = threadId + } + + override fun newThread(r: Runnable): Thread = + delegate.newThread(r).also { setThreadId(it.id) } + } +} \ No newline at end of file From 8651cca03c786b15021fa118de9fbbd95610510e Mon Sep 17 00:00:00 2001 From: Lachlan McKee Date: Mon, 18 Jul 2022 11:50:41 +0100 Subject: [PATCH 06/15] Updated MVICore demo to use featureScheduler --- mvicore-demo/mvicore-demo-feature2/build.gradle | 1 + .../src/main/java/com/badoo/feature2/Feature2.kt | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/mvicore-demo/mvicore-demo-feature2/build.gradle b/mvicore-demo/mvicore-demo-feature2/build.gradle index ce0050bf..b1f336e1 100644 --- a/mvicore-demo/mvicore-demo-feature2/build.gradle +++ b/mvicore-demo/mvicore-demo-feature2/build.gradle @@ -43,5 +43,6 @@ dependencies { implementation deps('io.reactivex.rxjava2:rxandroid') implementation project(":mvicore") + implementation project(":mvicore-android") implementation project(':mvicore-demo:mvicore-demo-catapi') } diff --git a/mvicore-demo/mvicore-demo-feature2/src/main/java/com/badoo/feature2/Feature2.kt b/mvicore-demo/mvicore-demo-feature2/src/main/java/com/badoo/feature2/Feature2.kt index 7f0d98e2..d339fa2f 100644 --- a/mvicore-demo/mvicore-demo-feature2/src/main/java/com/badoo/feature2/Feature2.kt +++ b/mvicore-demo/mvicore-demo-feature2/src/main/java/com/badoo/feature2/Feature2.kt @@ -11,6 +11,7 @@ import com.badoo.feature2.Feature2.News import com.badoo.feature2.Feature2.State import com.badoo.feature2.Feature2.Wish import com.badoo.feature2.Feature2.Wish.LoadNewImage +import com.badoo.mvicore.android.AndroidMainThreadFeatureScheduler import com.badoo.mvicore.element.Actor import com.badoo.mvicore.element.Bootstrapper import com.badoo.mvicore.element.NewsPublisher @@ -19,7 +20,6 @@ import com.badoo.mvicore.element.TimeCapsule import com.badoo.mvicore.feature.ActorReducerFeature import io.reactivex.Observable import io.reactivex.Observable.just -import io.reactivex.android.schedulers.AndroidSchedulers import kotlinx.android.parcel.Parcelize class Feature2( @@ -29,7 +29,8 @@ class Feature2( bootstrapper = BootStrapperImpl(), actor = ActorImpl(), reducer = ReducerImpl(), - newsPublisher = NewsPublisherImpl() + newsPublisher = NewsPublisherImpl(), + featureScheduler = AndroidMainThreadFeatureScheduler ) { init { timeCapsule?.register(Feature2::class.java) { state.copy( @@ -74,7 +75,6 @@ class Feature2( fun loadRandomImage(): Observable { return service.getRandomImage() .randomlyThrowAnException() - .observeOn(AndroidSchedulers.mainThread()) } } From 39baca8ae69ec0e6ca6c2eeef7c826f4f223fe9c Mon Sep 17 00:00:00 2001 From: Lachlan McKee Date: Fri, 22 Jul 2022 17:27:10 +0100 Subject: [PATCH 07/15] Improved feature scheduler switching logic --- .../java/com/badoo/mvicore/extension/Rx.kt | 5 ++ .../com/badoo/mvicore/feature/BaseFeature.kt | 56 ++++++------- .../feature/BaseFeatureWithSchedulerTest.kt | 79 +++++++++++++++---- 3 files changed, 98 insertions(+), 42 deletions(-) diff --git a/mvicore/src/main/java/com/badoo/mvicore/extension/Rx.kt b/mvicore/src/main/java/com/badoo/mvicore/extension/Rx.kt index 6d634369..e0722ac0 100644 --- a/mvicore/src/main/java/com/badoo/mvicore/extension/Rx.kt +++ b/mvicore/src/main/java/com/badoo/mvicore/extension/Rx.kt @@ -1,6 +1,11 @@ package com.badoo.mvicore.extension +import io.reactivex.Observable import io.reactivex.Observer +import io.reactivex.Scheduler import io.reactivex.functions.Consumer fun Observer.asConsumer() = Consumer { onNext(it) } + +internal fun Observable.subscribeOnNullable(scheduler: Scheduler?): Observable = + if (scheduler != null) subscribeOn(scheduler) else this diff --git a/mvicore/src/main/java/com/badoo/mvicore/feature/BaseFeature.kt b/mvicore/src/main/java/com/badoo/mvicore/feature/BaseFeature.kt index cd21e669..6c7125e9 100644 --- a/mvicore/src/main/java/com/badoo/mvicore/feature/BaseFeature.kt +++ b/mvicore/src/main/java/com/badoo/mvicore/feature/BaseFeature.kt @@ -9,6 +9,7 @@ import com.badoo.mvicore.element.Reducer import com.badoo.mvicore.element.WishToAction import com.badoo.mvicore.extension.SameThreadVerifier import com.badoo.mvicore.extension.asConsumer +import com.badoo.mvicore.extension.subscribeOnNullable import io.reactivex.Observable import io.reactivex.ObservableSource import io.reactivex.Observer @@ -72,9 +73,11 @@ open class BaseFeature + invokeActor(state, action) + } + .subscribe() if (bootstrapper != null) { setupBootstrapper(bootstrapper) @@ -90,20 +93,13 @@ open class BaseFeature disposables += output disposables += - if (featureScheduler == null || featureScheduler.isOnFeatureThread) { - bootstrapper.invoke().subscribe { - output.accept(it) + Observable + .defer { bootstrapper() } + .observeOnFeatureScheduler(featureScheduler) { action -> + output.accept(action) } - } else { - Observable - .defer { bootstrapper() } - .subscribeOn(featureScheduler.scheduler) - .subscribe { - // As the action subject is serialized, it doesn't matter if we - // are no longer on the feature scheduler thread. - output.accept(it) - } - } + .subscribeOnNullable(featureScheduler?.scheduler) + .subscribe() } } @@ -166,10 +162,8 @@ open class BaseFeature - featureScheduler.runOnFeatureThread { - invokeReducer(stateSubject.value!!, action, effect) - } + .observeOnFeatureScheduler(featureScheduler) { effect -> + invokeReducer(stateSubject.value!!, action, effect) } .subscribe() } @@ -284,14 +278,20 @@ open class BaseFeature Unit) { - if (this == null || isOnFeatureThread) { - func() - } else { - scheduler.scheduleDirect { func() } - } +fun Observable.observeOnFeatureScheduler( + scheduler: BaseFeature.FeatureScheduler?, + func: (T) -> Unit +): Observable = + flatMap { value -> + val upstream = Observable.just(value) + if (scheduler == null || scheduler.isOnFeatureThread) { + upstream + .doOnNext { func(it) } + } else { + upstream + .observeOn(scheduler.scheduler) + .doOnNext { func(it) } } } -} diff --git a/mvicore/src/test/java/com/badoo/mvicore/feature/BaseFeatureWithSchedulerTest.kt b/mvicore/src/test/java/com/badoo/mvicore/feature/BaseFeatureWithSchedulerTest.kt index 1642b14a..04b5ccc0 100644 --- a/mvicore/src/test/java/com/badoo/mvicore/feature/BaseFeatureWithSchedulerTest.kt +++ b/mvicore/src/test/java/com/badoo/mvicore/feature/BaseFeatureWithSchedulerTest.kt @@ -21,6 +21,7 @@ import com.badoo.mvicore.extension.SameThreadVerifier import com.badoo.mvicore.onNextEvents import io.reactivex.Observable import io.reactivex.Scheduler +import io.reactivex.disposables.Disposable import io.reactivex.internal.schedulers.RxThreadFactory import io.reactivex.observers.TestObserver import io.reactivex.plugins.RxJavaPlugins @@ -70,6 +71,7 @@ class BaseFeatureWithSchedulerTest { actorScheduler ), reducer = TestHelper.TestReducer(invocationCallback = { + println("${Thread.currentThread().name} reducer about to check if on feature thread") if (!featureScheduler.isOnFeatureThread) { fail("Reducer was not invoked on the feature thread") } @@ -236,7 +238,10 @@ class BaseFeatureWithSchedulerTest { testObserver.awaitAndAssertCount(8 + 1) val state = states.onNextEvents().last() as TestState - assertEquals((initialCounter + 4 * instantFulfillAmount1) * conditionalMultiplier, state.counter) + assertEquals( + (initialCounter + 4 * instantFulfillAmount1) * conditionalMultiplier, + state.counter + ) assertEquals(false, state.loading) } @@ -254,9 +259,18 @@ class BaseFeatureWithSchedulerTest { feature.accept(LoopbackWish1) actorInvocationLogTest.awaitAndAssertCount(4) - assertEquals(LoopbackWish1 to TestHelper.loopBackInitialState, actorInvocationLogTest.onNextEvents()[1]) - assertEquals(LoopbackWish2 to TestHelper.loopBackState1, actorInvocationLogTest.onNextEvents()[2]) - assertEquals(LoopbackWish3 to TestHelper.loopBackState2, actorInvocationLogTest.onNextEvents()[3]) + assertEquals( + LoopbackWish1 to TestHelper.loopBackInitialState, + actorInvocationLogTest.onNextEvents()[1] + ) + assertEquals( + LoopbackWish2 to TestHelper.loopBackState1, + actorInvocationLogTest.onNextEvents()[2] + ) + assertEquals( + LoopbackWish3 to TestHelper.loopBackState2, + actorInvocationLogTest.onNextEvents()[3] + ) } @Test @@ -267,15 +281,15 @@ class BaseFeatureWithSchedulerTest { } @Test - fun `if feature created on same thread, feature scheduler not be accessed for bootstrapping`() { + fun `if feature created on same thread, feature scheduler still accessed for bootstrapping`() { val latch = CountDownLatch(1) - featureScheduler.schedulerIncrementingCount.scheduleDirect { + featureScheduler.testScheduler.scheduleDirect { initFeature() latch.countDown() } latch.await(5, TimeUnit.SECONDS) - assertEquals(0, featureScheduler.schedulerInvocationCount) + assertEquals(1, featureScheduler.schedulerInvocationCount) } @Test @@ -302,21 +316,58 @@ class BaseFeatureWithSchedulerTest { } private class TestThreadFeatureScheduler : BaseFeature.FeatureScheduler { - var schedulerInvocationCount: Int = 0 + val schedulerInvocationCount: Int + get() = countingScheduler.interactionCount - val schedulerIncrementingCount by lazy { + val testScheduler by lazy { RxJavaPlugins - .createSingleScheduler(RxThreadFactory("AsyncTestScheduler", Thread.NORM_PRIORITY, false)) + .createSingleScheduler( + RxThreadFactory( + "AsyncTestScheduler", + Thread.NORM_PRIORITY, + false + ) + ) .apply { start() } } + private val countingScheduler: CountingScheduler by lazy { + CountingScheduler(delegate = testScheduler) + } + override val scheduler: Scheduler - get() { - schedulerInvocationCount++ - return schedulerIncrementingCount - } + get() = countingScheduler override val isOnFeatureThread: Boolean get() = Thread.currentThread().name.startsWith("AsyncTestScheduler") + + private class CountingScheduler(private val delegate: Scheduler) : Scheduler() { + var interactionCount: Int = 0 + + override fun createWorker(): Worker = delegate.createWorker().also { interactionCount++ } + + override fun start() { + delegate.start() + } + + override fun shutdown() { + delegate.shutdown() + } + + override fun scheduleDirect(run: Runnable): Disposable = + delegate.scheduleDirect(run).also { interactionCount++ } + + override fun scheduleDirect(run: Runnable, delay: Long, unit: TimeUnit): Disposable = + delegate.scheduleDirect(run, delay, unit).also { interactionCount++ } + + override fun schedulePeriodicallyDirect( + run: Runnable, + initialDelay: Long, + period: Long, + unit: TimeUnit + ): Disposable = + delegate.schedulePeriodicallyDirect(run, initialDelay, period, unit) + .also { interactionCount++ } + } } } From cbdb442e3361a4156076841dbcd1ef6da1f3ce12 Mon Sep 17 00:00:00 2001 From: Lachlan McKee Date: Fri, 22 Jul 2022 17:52:08 +0100 Subject: [PATCH 08/15] Reintroduced disposables memory leak fix --- .../com/badoo/mvicore/feature/BaseFeature.kt | 22 ++++++++++++++----- .../feature/BaseFeatureWithSchedulerTest.kt | 1 - 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/mvicore/src/main/java/com/badoo/mvicore/feature/BaseFeature.kt b/mvicore/src/main/java/com/badoo/mvicore/feature/BaseFeature.kt index 6c7125e9..c44de65f 100644 --- a/mvicore/src/main/java/com/badoo/mvicore/feature/BaseFeature.kt +++ b/mvicore/src/main/java/com/badoo/mvicore/feature/BaseFeature.kt @@ -160,12 +160,22 @@ open class BaseFeature - invokeReducer(stateSubject.value!!, action, effect) - } - .subscribe() + var disposable: Disposable? = null + disposable = + actor + .invoke(state, action) + .observeOnFeatureScheduler(featureScheduler) { effect -> + invokeReducer(stateSubject.value!!, action, effect) + } + .doAfterTerminate { + // Remove disposables manually because CompositeDisposable does not do it automatically producing memory leaks + // Check for null as it might be disposed instantly + disposable?.let(disposables::remove) + } + .subscribe() + + // Disposable might be already disposed in case of no scheduler + Observable.just + if (!disposable.isDisposed) disposables += disposable } private fun invokeReducer(state: State, action: Action, effect: Effect) { diff --git a/mvicore/src/test/java/com/badoo/mvicore/feature/BaseFeatureWithSchedulerTest.kt b/mvicore/src/test/java/com/badoo/mvicore/feature/BaseFeatureWithSchedulerTest.kt index 04b5ccc0..7ec9af37 100644 --- a/mvicore/src/test/java/com/badoo/mvicore/feature/BaseFeatureWithSchedulerTest.kt +++ b/mvicore/src/test/java/com/badoo/mvicore/feature/BaseFeatureWithSchedulerTest.kt @@ -71,7 +71,6 @@ class BaseFeatureWithSchedulerTest { actorScheduler ), reducer = TestHelper.TestReducer(invocationCallback = { - println("${Thread.currentThread().name} reducer about to check if on feature thread") if (!featureScheduler.isOnFeatureThread) { fail("Reducer was not invoked on the feature thread") } From b6eed1dc4a1c77c87ef845a07462a6ec5f0787ae Mon Sep 17 00:00:00 2001 From: Lachlan McKee Date: Fri, 22 Jul 2022 20:37:58 +0100 Subject: [PATCH 09/15] Added test to ensure BaseFeature is unit testable --- .../feature/FeatureSchedulerFactory.kt | 2 +- .../ActorReducerFeatureTestSchedulerTest.kt | 76 +++++++++++++++++++ .../feature/BaseFeatureWithSchedulerTest.kt | 22 ++---- 3 files changed, 85 insertions(+), 15 deletions(-) create mode 100644 mvicore/src/test/java/com/badoo/mvicore/feature/ActorReducerFeatureTestSchedulerTest.kt diff --git a/mvicore/src/main/java/com/badoo/mvicore/feature/FeatureSchedulerFactory.kt b/mvicore/src/main/java/com/badoo/mvicore/feature/FeatureSchedulerFactory.kt index 62785850..0f5828ba 100644 --- a/mvicore/src/main/java/com/badoo/mvicore/feature/FeatureSchedulerFactory.kt +++ b/mvicore/src/main/java/com/badoo/mvicore/feature/FeatureSchedulerFactory.kt @@ -55,4 +55,4 @@ object FeatureSchedulerFactory { override fun newThread(r: Runnable): Thread = delegate.newThread(r).also { setThreadId(it.id) } } -} \ No newline at end of file +} diff --git a/mvicore/src/test/java/com/badoo/mvicore/feature/ActorReducerFeatureTestSchedulerTest.kt b/mvicore/src/test/java/com/badoo/mvicore/feature/ActorReducerFeatureTestSchedulerTest.kt new file mode 100644 index 00000000..6e3e006f --- /dev/null +++ b/mvicore/src/test/java/com/badoo/mvicore/feature/ActorReducerFeatureTestSchedulerTest.kt @@ -0,0 +1,76 @@ +package com.badoo.mvicore.feature + +import com.badoo.mvicore.element.Actor +import com.badoo.mvicore.element.Reducer +import com.badoo.mvicore.feature.BaseFeature.FeatureScheduler +import com.badoo.mvicore.feature.ActorReducerFeatureTestSchedulerTest.TestFeature.Effect +import com.badoo.mvicore.feature.ActorReducerFeatureTestSchedulerTest.TestFeature.State +import com.badoo.mvicore.feature.ActorReducerFeatureTestSchedulerTest.TestFeature.Wish +import com.badoo.mvicore.onNextEvents +import io.reactivex.Observable +import io.reactivex.Scheduler +import io.reactivex.schedulers.Schedulers +import io.reactivex.schedulers.TestScheduler +import org.junit.Test +import java.util.concurrent.TimeUnit +import kotlin.test.assertEquals + +class ActorReducerFeatureTestSchedulerTest { + + @Test + fun `ensure computation scheduler works with feature scheduler`() { + val computationScheduler = TestScheduler() + val feature = TestFeature( + featureScheduler = TestFeatureScheduler, + computationScheduler = computationScheduler + ) + val states = Observable.wrap(feature).test() + + feature.accept(Wish.Trigger) + computationScheduler.advanceTimeBy(1, TimeUnit.MINUTES) + + val state = states.onNextEvents().last() as State + assertEquals(true, state.mutated) + } + + class TestFeature( + featureScheduler: FeatureScheduler, + computationScheduler: Scheduler = Schedulers.computation() + ) : ActorReducerFeature( + initialState = State(), + actor = ActorImpl(scheduler = computationScheduler), + reducer = ReducerImpl, + featureScheduler = featureScheduler + ) { + sealed class Wish { + object Trigger : Wish() + } + + sealed class Effect { + object Mutate : Effect() + } + + data class State(val mutated: Boolean = false) + + class ActorImpl(private val scheduler: Scheduler) : Actor { + override fun invoke(state: State, wish: Wish): Observable = + when (wish) { + Wish.Trigger -> Observable.timer(1, TimeUnit.MINUTES, scheduler) + .map { Effect.Mutate } + } + } + + object ReducerImpl : Reducer { + override fun invoke(state: State, effect: Effect): State = + when (effect) { + Effect.Mutate -> state.copy(mutated = true) + } + } + } + + private object TestFeatureScheduler : FeatureScheduler { + override val scheduler: Scheduler = Schedulers.trampoline() + + override val isOnFeatureThread: Boolean = false + } +} diff --git a/mvicore/src/test/java/com/badoo/mvicore/feature/BaseFeatureWithSchedulerTest.kt b/mvicore/src/test/java/com/badoo/mvicore/feature/BaseFeatureWithSchedulerTest.kt index 7ec9af37..cc35de15 100644 --- a/mvicore/src/test/java/com/badoo/mvicore/feature/BaseFeatureWithSchedulerTest.kt +++ b/mvicore/src/test/java/com/badoo/mvicore/feature/BaseFeatureWithSchedulerTest.kt @@ -22,9 +22,7 @@ import com.badoo.mvicore.onNextEvents import io.reactivex.Observable import io.reactivex.Scheduler import io.reactivex.disposables.Disposable -import io.reactivex.internal.schedulers.RxThreadFactory import io.reactivex.observers.TestObserver -import io.reactivex.plugins.RxJavaPlugins import io.reactivex.schedulers.Schedulers import io.reactivex.schedulers.TestScheduler import io.reactivex.subjects.PublishSubject @@ -318,18 +316,13 @@ class BaseFeatureWithSchedulerTest { val schedulerInvocationCount: Int get() = countingScheduler.interactionCount - val testScheduler by lazy { - RxJavaPlugins - .createSingleScheduler( - RxThreadFactory( - "AsyncTestScheduler", - Thread.NORM_PRIORITY, - false - ) - ) - .apply { start() } + private val delegate by lazy { + FeatureSchedulerFactory.create("AsyncTestScheduler") } + val testScheduler: Scheduler + get() = delegate.scheduler + private val countingScheduler: CountingScheduler by lazy { CountingScheduler(delegate = testScheduler) } @@ -338,12 +331,13 @@ class BaseFeatureWithSchedulerTest { get() = countingScheduler override val isOnFeatureThread: Boolean - get() = Thread.currentThread().name.startsWith("AsyncTestScheduler") + get() = delegate.isOnFeatureThread private class CountingScheduler(private val delegate: Scheduler) : Scheduler() { var interactionCount: Int = 0 - override fun createWorker(): Worker = delegate.createWorker().also { interactionCount++ } + override fun createWorker(): Worker = + delegate.createWorker().also { interactionCount++ } override fun start() { delegate.start() From b6817fa3ac66c4626d73f1a8351bef3eeea9467e Mon Sep 17 00:00:00 2001 From: Lachlan McKee Date: Sat, 23 Jul 2022 00:10:40 +0100 Subject: [PATCH 10/15] Restructured FeatureSchedulers --- .../AndroidMainThreadFeatureScheduler.kt | 4 ++-- .../mvicore/feature/AsyncFeatureSchedulers.kt | 12 ++++++++++++ .../badoo/mvicore/feature/BaseAsyncFeature.kt | 11 +---------- .../com/badoo/mvicore/feature/BaseFeature.kt | 16 +--------------- .../badoo/mvicore/feature/FeatureScheduler.kt | 19 +++++++++++++++++++ ...hedulerFactory.kt => FeatureSchedulers.kt} | 19 +++++++++++++++---- .../com/badoo/mvicore/feature/MemoFeature.kt | 2 +- .../ActorReducerFeatureTestSchedulerTest.kt | 10 ++-------- .../mvicore/feature/AsyncBaseFeatureTest.kt | 3 +-- .../feature/BaseFeatureWithSchedulerTest.kt | 4 ++-- 10 files changed, 56 insertions(+), 44 deletions(-) create mode 100644 mvicore/src/main/java/com/badoo/mvicore/feature/AsyncFeatureSchedulers.kt create mode 100644 mvicore/src/main/java/com/badoo/mvicore/feature/FeatureScheduler.kt rename mvicore/src/main/java/com/badoo/mvicore/feature/{FeatureSchedulerFactory.kt => FeatureSchedulers.kt} (81%) diff --git a/mvicore-android/src/main/java/com/badoo/mvicore/android/AndroidMainThreadFeatureScheduler.kt b/mvicore-android/src/main/java/com/badoo/mvicore/android/AndroidMainThreadFeatureScheduler.kt index bb27b998..9c750aa3 100644 --- a/mvicore-android/src/main/java/com/badoo/mvicore/android/AndroidMainThreadFeatureScheduler.kt +++ b/mvicore-android/src/main/java/com/badoo/mvicore/android/AndroidMainThreadFeatureScheduler.kt @@ -1,7 +1,7 @@ package com.badoo.mvicore.android import android.os.Looper -import com.badoo.mvicore.feature.BaseFeature +import com.badoo.mvicore.feature.FeatureScheduler import io.reactivex.Scheduler import io.reactivex.android.schedulers.AndroidSchedulers @@ -12,7 +12,7 @@ import io.reactivex.android.schedulers.AndroidSchedulers * It also uses the 'isOnFeatureThread' field to avoid observing on the main thread if it is already * the current thread. */ -object AndroidMainThreadFeatureScheduler: BaseFeature.FeatureScheduler { +object AndroidMainThreadFeatureScheduler: FeatureScheduler { override val scheduler: Scheduler get() = AndroidSchedulers.mainThread() diff --git a/mvicore/src/main/java/com/badoo/mvicore/feature/AsyncFeatureSchedulers.kt b/mvicore/src/main/java/com/badoo/mvicore/feature/AsyncFeatureSchedulers.kt new file mode 100644 index 00000000..42d24171 --- /dev/null +++ b/mvicore/src/main/java/com/badoo/mvicore/feature/AsyncFeatureSchedulers.kt @@ -0,0 +1,12 @@ +package com.badoo.mvicore.feature + +import io.reactivex.Scheduler + +/** + * A set of [Scheduler]s that change the threading behaviour of [BaseAsyncFeature] + */ +class AsyncFeatureSchedulers( + /** Should be single-threaded. */ + val featureScheduler: Scheduler, + val observationScheduler: Scheduler +) \ No newline at end of file diff --git a/mvicore/src/main/java/com/badoo/mvicore/feature/BaseAsyncFeature.kt b/mvicore/src/main/java/com/badoo/mvicore/feature/BaseAsyncFeature.kt index 918dec2a..a9ab6a59 100644 --- a/mvicore/src/main/java/com/badoo/mvicore/feature/BaseAsyncFeature.kt +++ b/mvicore/src/main/java/com/badoo/mvicore/feature/BaseAsyncFeature.kt @@ -30,7 +30,7 @@ open class BaseAsyncFeature, postProcessor: PostProcessor? = null, newsPublisher: NewsPublisher? = null, - private val schedulers: FeatureSchedulers + private val schedulers: AsyncFeatureSchedulers ) : AsyncFeature { private val threadVerifier by lazy { SameThreadVerifier() } @@ -284,13 +284,4 @@ open class BaseAsyncFeature Observable.observeOnFeatureScheduler( - scheduler: BaseFeature.FeatureScheduler?, + scheduler: FeatureScheduler?, func: (T) -> Unit ): Observable = flatMap { value -> diff --git a/mvicore/src/main/java/com/badoo/mvicore/feature/FeatureScheduler.kt b/mvicore/src/main/java/com/badoo/mvicore/feature/FeatureScheduler.kt new file mode 100644 index 00000000..096bf4c3 --- /dev/null +++ b/mvicore/src/main/java/com/badoo/mvicore/feature/FeatureScheduler.kt @@ -0,0 +1,19 @@ +package com.badoo.mvicore.feature + +import io.reactivex.Scheduler + +/** + * A set of [Scheduler]s that change the threading behaviour of [BaseFeature] + */ +interface FeatureScheduler { + /** + * The scheduler that this feature executes on. + * This must be single threaded, otherwise your feature will be non-deterministic. + */ + val scheduler: Scheduler + + /** + * Helps avoid sending a message to a thread if we are already on the thread. + */ + val isOnFeatureThread: Boolean +} diff --git a/mvicore/src/main/java/com/badoo/mvicore/feature/FeatureSchedulerFactory.kt b/mvicore/src/main/java/com/badoo/mvicore/feature/FeatureSchedulers.kt similarity index 81% rename from mvicore/src/main/java/com/badoo/mvicore/feature/FeatureSchedulerFactory.kt rename to mvicore/src/main/java/com/badoo/mvicore/feature/FeatureSchedulers.kt index 0f5828ba..ae02f0b8 100644 --- a/mvicore/src/main/java/com/badoo/mvicore/feature/FeatureSchedulerFactory.kt +++ b/mvicore/src/main/java/com/badoo/mvicore/feature/FeatureSchedulers.kt @@ -3,17 +3,19 @@ package com.badoo.mvicore.feature import io.reactivex.Scheduler import io.reactivex.internal.schedulers.RxThreadFactory import io.reactivex.plugins.RxJavaPlugins +import io.reactivex.schedulers.Schedulers import java.util.concurrent.ThreadFactory -object FeatureSchedulerFactory { +object FeatureSchedulers { /** * Creates a single threaded feature scheduler. */ - fun create( + @JvmStatic + fun createFeatureScheduler( threadPrefix: String, threadPriority: Int = Thread.NORM_PRIORITY - ): BaseFeature.FeatureScheduler { - return object : BaseFeature.FeatureScheduler { + ): FeatureScheduler { + return object : FeatureScheduler { private val singleThreadedThreadFactory by lazy { ThreadIdInterceptingThreadFactory(threadPrefix, threadPriority) } @@ -34,6 +36,15 @@ object FeatureSchedulerFactory { .createSingleScheduler(threadFactory) .apply { start() } + /** + * A feature scheduler that is useful for unit testing. + */ + object TrampolineFeatureScheduler : FeatureScheduler { + override val scheduler: Scheduler = Schedulers.trampoline() + + override val isOnFeatureThread: Boolean = false + } + /** * A thread factory which stores the thread id of the thread created. * This factory should only create one thread, otherwise it will throw an exception. diff --git a/mvicore/src/main/java/com/badoo/mvicore/feature/MemoFeature.kt b/mvicore/src/main/java/com/badoo/mvicore/feature/MemoFeature.kt index 32bdfd56..eb004be2 100644 --- a/mvicore/src/main/java/com/badoo/mvicore/feature/MemoFeature.kt +++ b/mvicore/src/main/java/com/badoo/mvicore/feature/MemoFeature.kt @@ -2,7 +2,7 @@ package com.badoo.mvicore.feature open class MemoFeature( initialState: State, - featureScheduler: BaseFeature.FeatureScheduler? = null + featureScheduler: FeatureScheduler? = null ) : Feature by ReducerFeature( initialState = initialState, reducer = { _, effect -> effect }, diff --git a/mvicore/src/test/java/com/badoo/mvicore/feature/ActorReducerFeatureTestSchedulerTest.kt b/mvicore/src/test/java/com/badoo/mvicore/feature/ActorReducerFeatureTestSchedulerTest.kt index 6e3e006f..bab47669 100644 --- a/mvicore/src/test/java/com/badoo/mvicore/feature/ActorReducerFeatureTestSchedulerTest.kt +++ b/mvicore/src/test/java/com/badoo/mvicore/feature/ActorReducerFeatureTestSchedulerTest.kt @@ -2,10 +2,10 @@ package com.badoo.mvicore.feature import com.badoo.mvicore.element.Actor import com.badoo.mvicore.element.Reducer -import com.badoo.mvicore.feature.BaseFeature.FeatureScheduler import com.badoo.mvicore.feature.ActorReducerFeatureTestSchedulerTest.TestFeature.Effect import com.badoo.mvicore.feature.ActorReducerFeatureTestSchedulerTest.TestFeature.State import com.badoo.mvicore.feature.ActorReducerFeatureTestSchedulerTest.TestFeature.Wish +import com.badoo.mvicore.feature.FeatureSchedulers.TrampolineFeatureScheduler import com.badoo.mvicore.onNextEvents import io.reactivex.Observable import io.reactivex.Scheduler @@ -21,7 +21,7 @@ class ActorReducerFeatureTestSchedulerTest { fun `ensure computation scheduler works with feature scheduler`() { val computationScheduler = TestScheduler() val feature = TestFeature( - featureScheduler = TestFeatureScheduler, + featureScheduler = TrampolineFeatureScheduler, computationScheduler = computationScheduler ) val states = Observable.wrap(feature).test() @@ -67,10 +67,4 @@ class ActorReducerFeatureTestSchedulerTest { } } } - - private object TestFeatureScheduler : FeatureScheduler { - override val scheduler: Scheduler = Schedulers.trampoline() - - override val isOnFeatureThread: Boolean = false - } } diff --git a/mvicore/src/test/java/com/badoo/mvicore/feature/AsyncBaseFeatureTest.kt b/mvicore/src/test/java/com/badoo/mvicore/feature/AsyncBaseFeatureTest.kt index c6b5ac7a..ffa2e431 100644 --- a/mvicore/src/test/java/com/badoo/mvicore/feature/AsyncBaseFeatureTest.kt +++ b/mvicore/src/test/java/com/badoo/mvicore/feature/AsyncBaseFeatureTest.kt @@ -6,7 +6,6 @@ import com.badoo.mvicore.element.NewsPublisher import com.badoo.mvicore.element.PostProcessor import com.badoo.mvicore.element.Reducer import com.badoo.mvicore.element.WishToAction -import com.badoo.mvicore.feature.BaseAsyncFeature.FeatureSchedulers import com.badoo.mvicore.utils.RxErrorRule import io.reactivex.Observable import io.reactivex.ObservableSource @@ -187,7 +186,7 @@ class AsyncBaseFeatureTest { reducer = reducer, newsPublisher = newsPublisher, postProcessor = postProcessor, - schedulers = FeatureSchedulers( + schedulers = AsyncFeatureSchedulers( featureScheduler = featureScheduler, observationScheduler = observationScheduler ) diff --git a/mvicore/src/test/java/com/badoo/mvicore/feature/BaseFeatureWithSchedulerTest.kt b/mvicore/src/test/java/com/badoo/mvicore/feature/BaseFeatureWithSchedulerTest.kt index cc35de15..0e089f27 100644 --- a/mvicore/src/test/java/com/badoo/mvicore/feature/BaseFeatureWithSchedulerTest.kt +++ b/mvicore/src/test/java/com/badoo/mvicore/feature/BaseFeatureWithSchedulerTest.kt @@ -312,12 +312,12 @@ class BaseFeatureWithSchedulerTest { assertValueCount(count) } - private class TestThreadFeatureScheduler : BaseFeature.FeatureScheduler { + private class TestThreadFeatureScheduler : FeatureScheduler { val schedulerInvocationCount: Int get() = countingScheduler.interactionCount private val delegate by lazy { - FeatureSchedulerFactory.create("AsyncTestScheduler") + FeatureSchedulers.createFeatureScheduler("AsyncTestScheduler") } val testScheduler: Scheduler From a58c7b8de15027129c57d3a2c57f6502ab5ecd3b Mon Sep 17 00:00:00 2001 From: Lachlan McKee Date: Wed, 3 Aug 2022 15:02:49 +0100 Subject: [PATCH 11/15] Renamed ActorReducerFeatureTestSchedulerTest --- ...erTest.kt => TrampolineFeatureSchedulerTest.kt} | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) rename mvicore/src/test/java/com/badoo/mvicore/feature/{ActorReducerFeatureTestSchedulerTest.kt => TrampolineFeatureSchedulerTest.kt} (79%) diff --git a/mvicore/src/test/java/com/badoo/mvicore/feature/ActorReducerFeatureTestSchedulerTest.kt b/mvicore/src/test/java/com/badoo/mvicore/feature/TrampolineFeatureSchedulerTest.kt similarity index 79% rename from mvicore/src/test/java/com/badoo/mvicore/feature/ActorReducerFeatureTestSchedulerTest.kt rename to mvicore/src/test/java/com/badoo/mvicore/feature/TrampolineFeatureSchedulerTest.kt index bab47669..8e237604 100644 --- a/mvicore/src/test/java/com/badoo/mvicore/feature/ActorReducerFeatureTestSchedulerTest.kt +++ b/mvicore/src/test/java/com/badoo/mvicore/feature/TrampolineFeatureSchedulerTest.kt @@ -2,11 +2,10 @@ package com.badoo.mvicore.feature import com.badoo.mvicore.element.Actor import com.badoo.mvicore.element.Reducer -import com.badoo.mvicore.feature.ActorReducerFeatureTestSchedulerTest.TestFeature.Effect -import com.badoo.mvicore.feature.ActorReducerFeatureTestSchedulerTest.TestFeature.State -import com.badoo.mvicore.feature.ActorReducerFeatureTestSchedulerTest.TestFeature.Wish +import com.badoo.mvicore.feature.TrampolineFeatureSchedulerTest.TestFeature.Effect +import com.badoo.mvicore.feature.TrampolineFeatureSchedulerTest.TestFeature.State +import com.badoo.mvicore.feature.TrampolineFeatureSchedulerTest.TestFeature.Wish import com.badoo.mvicore.feature.FeatureSchedulers.TrampolineFeatureScheduler -import com.badoo.mvicore.onNextEvents import io.reactivex.Observable import io.reactivex.Scheduler import io.reactivex.schedulers.Schedulers @@ -15,10 +14,10 @@ import org.junit.Test import java.util.concurrent.TimeUnit import kotlin.test.assertEquals -class ActorReducerFeatureTestSchedulerTest { +class TrampolineFeatureSchedulerTest { @Test - fun `ensure computation scheduler works with feature scheduler`() { + fun `ensure feature is testable with trampoline scheduler`() { val computationScheduler = TestScheduler() val feature = TestFeature( featureScheduler = TrampolineFeatureScheduler, @@ -29,8 +28,7 @@ class ActorReducerFeatureTestSchedulerTest { feature.accept(Wish.Trigger) computationScheduler.advanceTimeBy(1, TimeUnit.MINUTES) - val state = states.onNextEvents().last() as State - assertEquals(true, state.mutated) + assertEquals(true, states.values().last().mutated) } class TestFeature( From dcaef225fefe2e7a16ed0024f46d8416ed5e48d9 Mon Sep 17 00:00:00 2001 From: Lachlan McKee Date: Wed, 3 Aug 2022 15:06:08 +0100 Subject: [PATCH 12/15] Removed unused mockito invocation --- .../com/badoo/mvicore/feature/BaseFeatureWithSchedulerTest.kt | 2 -- .../badoo/mvicore/feature/BaseFeatureWithoutSchedulerTest.kt | 2 -- 2 files changed, 4 deletions(-) diff --git a/mvicore/src/test/java/com/badoo/mvicore/feature/BaseFeatureWithSchedulerTest.kt b/mvicore/src/test/java/com/badoo/mvicore/feature/BaseFeatureWithSchedulerTest.kt index 0e089f27..8d3b9152 100644 --- a/mvicore/src/test/java/com/badoo/mvicore/feature/BaseFeatureWithSchedulerTest.kt +++ b/mvicore/src/test/java/com/badoo/mvicore/feature/BaseFeatureWithSchedulerTest.kt @@ -28,7 +28,6 @@ import io.reactivex.schedulers.TestScheduler import io.reactivex.subjects.PublishSubject import org.junit.Before import org.junit.Test -import org.mockito.MockitoAnnotations import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit import kotlin.test.assertEquals @@ -45,7 +44,6 @@ class BaseFeatureWithSchedulerTest { @Before fun prepare() { - MockitoAnnotations.initMocks(this) SameThreadVerifier.isEnabled = true newsSubject = PublishSubject.create() diff --git a/mvicore/src/test/java/com/badoo/mvicore/feature/BaseFeatureWithoutSchedulerTest.kt b/mvicore/src/test/java/com/badoo/mvicore/feature/BaseFeatureWithoutSchedulerTest.kt index e9a05642..f6c52282 100644 --- a/mvicore/src/test/java/com/badoo/mvicore/feature/BaseFeatureWithoutSchedulerTest.kt +++ b/mvicore/src/test/java/com/badoo/mvicore/feature/BaseFeatureWithoutSchedulerTest.kt @@ -24,7 +24,6 @@ import io.reactivex.schedulers.TestScheduler import io.reactivex.subjects.PublishSubject import org.junit.Before import org.junit.Test -import org.mockito.MockitoAnnotations import java.util.concurrent.TimeUnit import kotlin.test.assertEquals @@ -38,7 +37,6 @@ class BaseFeatureWithoutSchedulerTest { @Before fun prepare() { - MockitoAnnotations.initMocks(this) SameThreadVerifier.isEnabled = false newsSubject = PublishSubject.create() From 1cbb803cbc31487bd1bf5f827269cd131aa83b9b Mon Sep 17 00:00:00 2001 From: Lachlan McKee Date: Wed, 3 Aug 2022 15:07:14 +0100 Subject: [PATCH 13/15] Minor codestyle improvements --- .../com/badoo/mvicore/feature/BaseFeature.kt | 32 ++++++++++--------- .../feature/BaseFeatureWithSchedulerTest.kt | 13 +++++--- .../BaseFeatureWithoutSchedulerTest.kt | 15 ++++++--- .../feature/TrampolineFeatureSchedulerTest.kt | 3 +- 4 files changed, 38 insertions(+), 25 deletions(-) diff --git a/mvicore/src/main/java/com/badoo/mvicore/feature/BaseFeature.kt b/mvicore/src/main/java/com/badoo/mvicore/feature/BaseFeature.kt index 6e49fa58..8ae039dc 100644 --- a/mvicore/src/main/java/com/badoo/mvicore/feature/BaseFeature.kt +++ b/mvicore/src/main/java/com/badoo/mvicore/feature/BaseFeature.kt @@ -274,20 +274,22 @@ open class BaseFeature Observable.observeOnFeatureScheduler( - scheduler: FeatureScheduler?, - func: (T) -> Unit -): Observable = - flatMap { value -> - val upstream = Observable.just(value) - if (scheduler == null || scheduler.isOnFeatureThread) { - upstream - .doOnNext { func(it) } - } else { - upstream - .observeOn(scheduler.scheduler) - .doOnNext { func(it) } - } + private companion object { + private fun Observable.observeOnFeatureScheduler( + scheduler: FeatureScheduler?, + func: (T) -> Unit + ): Observable = + flatMap { value -> + val upstream = Observable.just(value) + if (scheduler == null || scheduler.isOnFeatureThread) { + upstream + .doOnNext { func(it) } + } else { + upstream + .observeOn(scheduler.scheduler) + .doOnNext { func(it) } + } + } } +} diff --git a/mvicore/src/test/java/com/badoo/mvicore/feature/BaseFeatureWithSchedulerTest.kt b/mvicore/src/test/java/com/badoo/mvicore/feature/BaseFeatureWithSchedulerTest.kt index 8d3b9152..0c717d65 100644 --- a/mvicore/src/test/java/com/badoo/mvicore/feature/BaseFeatureWithSchedulerTest.kt +++ b/mvicore/src/test/java/com/badoo/mvicore/feature/BaseFeatureWithSchedulerTest.kt @@ -19,6 +19,7 @@ import com.badoo.mvicore.TestHelper.TestWish.TranslatesTo3Effects import com.badoo.mvicore.TestHelper.TestWish.Unfulfillable import com.badoo.mvicore.extension.SameThreadVerifier import com.badoo.mvicore.onNextEvents +import com.badoo.mvicore.utils.RxErrorRule import io.reactivex.Observable import io.reactivex.Scheduler import io.reactivex.disposables.Disposable @@ -27,6 +28,7 @@ import io.reactivex.schedulers.Schedulers import io.reactivex.schedulers.TestScheduler import io.reactivex.subjects.PublishSubject import org.junit.Before +import org.junit.Rule import org.junit.Test import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit @@ -42,6 +44,9 @@ class BaseFeatureWithSchedulerTest { private lateinit var actorScheduler: Scheduler private val featureScheduler = TestThreadFeatureScheduler() + @get:Rule + val rxRule = RxErrorRule() + @Before fun prepare() { SameThreadVerifier.isEnabled = true @@ -153,7 +158,7 @@ class BaseFeatureWithSchedulerTest { wishes.forEach { feature.accept(it) } testObserver.awaitAndAssertCount(1 + wishes.size) - val state = states.onNextEvents().last() as TestState + val state = states.values().last() assertEquals(initialCounter + wishes.size * instantFulfillAmount1, state.counter) assertEquals(false, state.loading) } @@ -168,7 +173,7 @@ class BaseFeatureWithSchedulerTest { wishes.forEach { feature.accept(it) } testObserver.awaitAndAssertCount(1 + wishes.size) - val state = states.onNextEvents().last() as TestState + val state = states.values().last() assertEquals(true, state.loading) assertEquals(initialCounter, state.counter) } @@ -191,7 +196,7 @@ class BaseFeatureWithSchedulerTest { testScheduler.advanceTimeBy(mockServerDelayMs, TimeUnit.MILLISECONDS) testObserver.awaitAndAssertCount(2 + wishes.size) - val state = states.onNextEvents().last() as TestState + val state = states.values().last() assertEquals(false, state.loading) assertEquals(initialCounter + TestHelper.delayedFulfillAmount, state.counter) } @@ -232,7 +237,7 @@ class BaseFeatureWithSchedulerTest { wishes.forEach { feature.accept(it) } testObserver.awaitAndAssertCount(8 + 1) - val state = states.onNextEvents().last() as TestState + val state = states.values().last() assertEquals( (initialCounter + 4 * instantFulfillAmount1) * conditionalMultiplier, state.counter diff --git a/mvicore/src/test/java/com/badoo/mvicore/feature/BaseFeatureWithoutSchedulerTest.kt b/mvicore/src/test/java/com/badoo/mvicore/feature/BaseFeatureWithoutSchedulerTest.kt index f6c52282..c20d04d4 100644 --- a/mvicore/src/test/java/com/badoo/mvicore/feature/BaseFeatureWithoutSchedulerTest.kt +++ b/mvicore/src/test/java/com/badoo/mvicore/feature/BaseFeatureWithoutSchedulerTest.kt @@ -22,6 +22,7 @@ import com.badoo.mvicore.onNextEvents import io.reactivex.observers.TestObserver import io.reactivex.schedulers.TestScheduler import io.reactivex.subjects.PublishSubject +import org.junit.After import org.junit.Before import org.junit.Test import java.util.concurrent.TimeUnit @@ -62,6 +63,12 @@ class BaseFeatureWithoutSchedulerTest { feature.news.subscribe(newsSubject) } + @After + fun teardown() { + // Reset back to the default to ensure we don't introduce flaky behaviours + SameThreadVerifier.isEnabled = true + } + @Test fun `if there are no wishes, feature only emits initial state`() { assertEquals(1, states.onNextEvents().size) @@ -120,7 +127,7 @@ class BaseFeatureWithoutSchedulerTest { wishes.forEach { feature.accept(it) } - val state = states.onNextEvents().last() as TestState + val state = states.values().last() assertEquals(initialCounter + wishes.size * instantFulfillAmount1, state.counter) assertEquals(false, state.loading) } @@ -133,7 +140,7 @@ class BaseFeatureWithoutSchedulerTest { wishes.forEach { feature.accept(it) } - val state = states.onNextEvents().last() as TestState + val state = states.values().last() assertEquals(true, state.loading) assertEquals(initialCounter, state.counter) } @@ -150,7 +157,7 @@ class BaseFeatureWithoutSchedulerTest { actorScheduler.advanceTimeBy(mockServerDelayMs, TimeUnit.MILLISECONDS) - val state = states.onNextEvents().last() as TestState + val state = states.values().last() assertEquals(false, state.loading) assertEquals(initialCounter + TestHelper.delayedFulfillAmount, state.counter) } @@ -188,7 +195,7 @@ class BaseFeatureWithoutSchedulerTest { wishes.forEach { feature.accept(it) } - val state = states.onNextEvents().last() as TestState + val state = states.values().last() assertEquals((initialCounter + 4 * instantFulfillAmount1) * conditionalMultiplier, state.counter) assertEquals(false, state.loading) } diff --git a/mvicore/src/test/java/com/badoo/mvicore/feature/TrampolineFeatureSchedulerTest.kt b/mvicore/src/test/java/com/badoo/mvicore/feature/TrampolineFeatureSchedulerTest.kt index 8e237604..7847c91f 100644 --- a/mvicore/src/test/java/com/badoo/mvicore/feature/TrampolineFeatureSchedulerTest.kt +++ b/mvicore/src/test/java/com/badoo/mvicore/feature/TrampolineFeatureSchedulerTest.kt @@ -8,7 +8,6 @@ import com.badoo.mvicore.feature.TrampolineFeatureSchedulerTest.TestFeature.Wish import com.badoo.mvicore.feature.FeatureSchedulers.TrampolineFeatureScheduler import io.reactivex.Observable import io.reactivex.Scheduler -import io.reactivex.schedulers.Schedulers import io.reactivex.schedulers.TestScheduler import org.junit.Test import java.util.concurrent.TimeUnit @@ -33,7 +32,7 @@ class TrampolineFeatureSchedulerTest { class TestFeature( featureScheduler: FeatureScheduler, - computationScheduler: Scheduler = Schedulers.computation() + computationScheduler: Scheduler ) : ActorReducerFeature( initialState = State(), actor = ActorImpl(scheduler = computationScheduler), From 53aa5f37862fcf7f19f25e8036afbd7d21693165 Mon Sep 17 00:00:00 2001 From: Lachlan McKee Date: Wed, 3 Aug 2022 15:48:29 +0100 Subject: [PATCH 14/15] Removed onNext behaviour from observeOnFeatureScheduler --- .../com/badoo/mvicore/feature/BaseFeature.kt | 25 ++++++------------- 1 file changed, 8 insertions(+), 17 deletions(-) diff --git a/mvicore/src/main/java/com/badoo/mvicore/feature/BaseFeature.kt b/mvicore/src/main/java/com/badoo/mvicore/feature/BaseFeature.kt index 8ae039dc..ab82ab80 100644 --- a/mvicore/src/main/java/com/badoo/mvicore/feature/BaseFeature.kt +++ b/mvicore/src/main/java/com/badoo/mvicore/feature/BaseFeature.kt @@ -73,10 +73,8 @@ open class BaseFeature - invokeActor(state, action) - } - .subscribe() + .observeOnFeatureScheduler(featureScheduler) + .subscribe { action -> invokeActor(state, action) } if (bootstrapper != null) { setupBootstrapper(bootstrapper) @@ -94,11 +92,9 @@ open class BaseFeature - output.accept(action) - } + .observeOnFeatureScheduler(featureScheduler) .subscribeOnNullable(featureScheduler?.scheduler) - .subscribe() + .subscribe { action -> output.accept(action) } } } @@ -163,15 +159,13 @@ open class BaseFeature - invokeReducer(stateSubject.value!!, action, effect) - } + .observeOnFeatureScheduler(featureScheduler) .doAfterTerminate { // Remove disposables manually because CompositeDisposable does not do it automatically producing memory leaks // Check for null as it might be disposed instantly disposable?.let(disposables::remove) } - .subscribe() + .subscribe { effect -> invokeReducer(stateSubject.value!!, action, effect) } // Disposable might be already disposed in case of no scheduler + Observable.just if (!disposable.isDisposed) disposables += disposable @@ -277,18 +271,15 @@ open class BaseFeature Observable.observeOnFeatureScheduler( - scheduler: FeatureScheduler?, - func: (T) -> Unit + scheduler: FeatureScheduler? ): Observable = flatMap { value -> val upstream = Observable.just(value) if (scheduler == null || scheduler.isOnFeatureThread) { upstream - .doOnNext { func(it) } } else { upstream - .observeOn(scheduler.scheduler) - .doOnNext { func(it) } + .subscribeOn(scheduler.scheduler) } } } From d241d13dc1a7b6f4d65a272056afe58125ffae46 Mon Sep 17 00:00:00 2001 From: Lachlan McKee Date: Thu, 4 Aug 2022 13:57:00 +0100 Subject: [PATCH 15/15] Updated JavaDoc to explain featureScheduler behaviour --- .../badoo/mvicore/feature/ActorReducerFeature.kt | 14 ++++++++++++++ .../java/com/badoo/mvicore/feature/BaseFeature.kt | 14 ++++++++++++++ .../java/com/badoo/mvicore/feature/MemoFeature.kt | 14 ++++++++++++++ .../com/badoo/mvicore/feature/ReducerFeature.kt | 14 ++++++++++++++ 4 files changed, 56 insertions(+) diff --git a/mvicore/src/main/java/com/badoo/mvicore/feature/ActorReducerFeature.kt b/mvicore/src/main/java/com/badoo/mvicore/feature/ActorReducerFeature.kt index 612825cf..a15d0437 100644 --- a/mvicore/src/main/java/com/badoo/mvicore/feature/ActorReducerFeature.kt +++ b/mvicore/src/main/java/com/badoo/mvicore/feature/ActorReducerFeature.kt @@ -5,6 +5,20 @@ import com.badoo.mvicore.element.Bootstrapper import com.badoo.mvicore.element.NewsPublisher import com.badoo.mvicore.element.Reducer +/** + * An implementation of a single threaded feature. + * + * Please be aware of the following threading behaviours based on whether a 'featureScheduler' is provided. + * + * No 'featureScheduler' provided: + * The feature must execute on the thread that created the class. If the bootstrapper/actor observables + * change to a different thread it is your responsibility to switch back to the feature's original + * thread via observeOn, otherwise an exception will be thrown. + * + * 'featureScheduler' provided (this must be single threaded): + * The feature does not have to execute on the thread that created the class. It automatically + * switches to the feature scheduler thread when necessary. + */ open class ActorReducerFeature( initialState: State, bootstrapper: Bootstrapper? = null, diff --git a/mvicore/src/main/java/com/badoo/mvicore/feature/BaseFeature.kt b/mvicore/src/main/java/com/badoo/mvicore/feature/BaseFeature.kt index ab82ab80..f22c3745 100644 --- a/mvicore/src/main/java/com/badoo/mvicore/feature/BaseFeature.kt +++ b/mvicore/src/main/java/com/badoo/mvicore/feature/BaseFeature.kt @@ -21,6 +21,20 @@ import io.reactivex.subjects.BehaviorSubject import io.reactivex.subjects.PublishSubject import io.reactivex.subjects.Subject +/** + * A base implementation of a single threaded feature. + * + * Please be aware of the following threading behaviours based on whether a 'featureScheduler' is provided. + * + * No 'featureScheduler' provided: + * The feature must execute on the thread that created the class. If the bootstrapper/actor observables + * change to a different thread it is your responsibility to switch back to the feature's original + * thread via observeOn, otherwise an exception will be thrown. + * + * 'featureScheduler' provided (this must be single threaded): + * The feature does not have to execute on the thread that created the class. It automatically + * switches to the feature scheduler thread when necessary. + */ open class BaseFeature( initialState: State, bootstrapper: Bootstrapper? = null, diff --git a/mvicore/src/main/java/com/badoo/mvicore/feature/MemoFeature.kt b/mvicore/src/main/java/com/badoo/mvicore/feature/MemoFeature.kt index eb004be2..e6d27e4e 100644 --- a/mvicore/src/main/java/com/badoo/mvicore/feature/MemoFeature.kt +++ b/mvicore/src/main/java/com/badoo/mvicore/feature/MemoFeature.kt @@ -1,5 +1,19 @@ package com.badoo.mvicore.feature +/** + * An implementation of a single threaded feature. + * + * Please be aware of the following threading behaviours based on whether a 'featureScheduler' is provided. + * + * No 'featureScheduler' provided: + * The feature must execute on the thread that created the class. If the bootstrapper/actor observables + * change to a different thread it is your responsibility to switch back to the feature's original + * thread via observeOn, otherwise an exception will be thrown. + * + * 'featureScheduler' provided (this must be single threaded): + * The feature does not have to execute on the thread that created the class. It automatically + * switches to the feature scheduler thread when necessary. + */ open class MemoFeature( initialState: State, featureScheduler: FeatureScheduler? = null diff --git a/mvicore/src/main/java/com/badoo/mvicore/feature/ReducerFeature.kt b/mvicore/src/main/java/com/badoo/mvicore/feature/ReducerFeature.kt index 38d8538b..65ee99e7 100644 --- a/mvicore/src/main/java/com/badoo/mvicore/feature/ReducerFeature.kt +++ b/mvicore/src/main/java/com/badoo/mvicore/feature/ReducerFeature.kt @@ -8,6 +8,20 @@ import com.badoo.mvicore.element.Reducer import io.reactivex.Observable import io.reactivex.Observable.just +/** + * An implementation of a single threaded feature. + * + * Please be aware of the following threading behaviours based on whether a 'featureScheduler' is provided. + * + * No 'featureScheduler' provided: + * The feature must execute on the thread that created the class. If the bootstrapper/actor observables + * change to a different thread it is your responsibility to switch back to the feature's original + * thread via observeOn, otherwise an exception will be thrown. + * + * 'featureScheduler' provided (this must be single threaded): + * The feature does not have to execute on the thread that created the class. It automatically + * switches to the feature scheduler thread when necessary. + */ open class ReducerFeature( initialState: State, reducer: Reducer,