diff --git a/.github/workflows/lint_and_tests.yml b/.github/workflows/lint_and_tests.yml index 7032c411..f66a03c3 100644 --- a/.github/workflows/lint_and_tests.yml +++ b/.github/workflows/lint_and_tests.yml @@ -23,6 +23,18 @@ jobs: - name: Run Lint run: ./gradlew lint + - name: Test results Debug + uses: actions/upload-artifact@v2 + with: + name: debug-unit-tests-results + path: Kotlin-Bloc/Kotlin-Bloc/bloc-core/build/reports/tests/testDebugUnitTest/ + + - name: Test results Release + uses: actions/upload-artifact@v2 + with: + name: release-unit-tests-results + path: Kotlin-Bloc/Kotlin-Bloc/bloc-core/build/reports/tests/testReleaseUnitTest/ + tests: runs-on: ubuntu-latest diff --git a/README.md b/README.md index ed164318..3c567333 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ - **predictable**: write reactive applications that behave consistently and are easy to debug and test - **composable**: grows with the complexity of the app and the size of the team -Bloc Architecture - Overview
+Bloc Architecture - Overview
- The `Bloc` (Business Logic Component) encapsulates the application's business logic. It receives `Action(s)` from the view, processes those actions and outputs `Proposals` and optionally `SideEffect(s)`. - The `BlocState` holds the component's `State`. It's separate from the actual `Bloc` to support different scenarios like: @@ -27,13 +27,13 @@ Note, this readme offers a quick overview of the framework. For more in-depth in ```kotlin dependencies { // the core library - implementation("com.1gravity:bloc-core:0.7.0") + implementation("com.1gravity:bloc-core:0.8.0") // add to use the framework together with Redux - implementation("com.1gravity:bloc-redux:0.7.0") + implementation("com.1gravity:bloc-redux:0.8.0") // useful extensions for Android and Jetpack/JetBrains Compose - implementation("com.1gravity:bloc-compose:0.7.0") + implementation("com.1gravity:bloc-compose:0.8.0") } ``` diff --git a/bloc-core/build.gradle.kts b/bloc-core/build.gradle.kts index ab492cc6..2df67ede 100644 --- a/bloc-core/build.gradle.kts +++ b/bloc-core/build.gradle.kts @@ -58,6 +58,8 @@ kotlin { // Logging (https://github.com/touchlab/Kermit) implementation(Touchlab.kermit) + implementation("org.jetbrains.kotlinx:atomicfu:_") + // Kotlin Result (https://github.com/michaelbull/kotlin-result) implementation("com.michael-bull.kotlin-result:kotlin-result:_") implementation("com.michael-bull.kotlin-result:kotlin-result-coroutines:_") diff --git a/bloc-core/src/commonMain/kotlin/com/onegravity/bloc/BlocDsl.kt b/bloc-core/src/commonMain/kotlin/com/onegravity/bloc/BlocDsl.kt index 5d438020..1c9996f3 100644 --- a/bloc-core/src/commonMain/kotlin/com/onegravity/bloc/BlocDsl.kt +++ b/bloc-core/src/commonMain/kotlin/com/onegravity/bloc/BlocDsl.kt @@ -5,33 +5,6 @@ package com.onegravity.bloc import com.onegravity.bloc.internal.BlocExtension import com.onegravity.bloc.utils.* -/** - * Submit an Initializer to a Bloc to be run. - * The initializer will receive the state and a dispatch function. - * The dispatch function dispatches to the first matching thunk/reducer/side-effect in the Bloc. - */ -@BlocDSL -public fun Bloc.onCreate( - initializer: Initializer -) { - // we assume that every class implementing Bloc also implements BlocExtension - // since we provide all concrete Bloc implementations, this is guaranteed - // the proposal is irrelevant for an initializer so we set it to Unit - (this as BlocExtension).initialize(initializer) -} - -/** - * Submit an Initializer to a BlocOwner/Bloc to be run. - * The initializer will receive the state and a dispatch function. - * The dispatch function dispatches to the first matching thunk/reducer/side-effect in the Bloc. - */ -@BlocDSL -public fun BlocOwner.onCreate( - initializer: Initializer -) { - bloc.onCreate(initializer) -} - /** * Submit a Thunk to a Bloc to be run. * The thunk will receive the dispatch and the getState function but no action (since it was diff --git a/bloc-core/src/commonMain/kotlin/com/onegravity/bloc/internal/BlocImpl.kt b/bloc-core/src/commonMain/kotlin/com/onegravity/bloc/internal/BlocImpl.kt index 2075ef98..d90dce7a 100644 --- a/bloc-core/src/commonMain/kotlin/com/onegravity/bloc/internal/BlocImpl.kt +++ b/bloc-core/src/commonMain/kotlin/com/onegravity/bloc/internal/BlocImpl.kt @@ -3,13 +3,15 @@ package com.onegravity.bloc.internal import com.arkivanov.essenty.lifecycle.* import com.onegravity.bloc.Bloc import com.onegravity.bloc.BlocContext +import com.onegravity.bloc.internal.lifecycle.subscribe import com.onegravity.bloc.internal.builder.MatcherReducer import com.onegravity.bloc.internal.builder.MatcherThunk +import com.onegravity.bloc.internal.lifecycle.BlocLifecycle +import com.onegravity.bloc.internal.lifecycle.BlocLifecycleImpl import com.onegravity.bloc.state.BlocState import com.onegravity.bloc.utils.* import kotlinx.coroutines.* import kotlinx.coroutines.flow.FlowCollector -import kotlin.coroutines.CoroutineContext /** * The probably most important class in the framework. @@ -23,36 +25,77 @@ internal class BlocImpl? = null, thunks: List> = emptyList(), reducers: List>>, - initDispatcher: CoroutineContext = Dispatchers.Default, - thunkDispatcher: CoroutineContext = Dispatchers.Default, - reduceDispatcher: CoroutineContext = Dispatchers.Default + initDispatcher: CoroutineDispatcher = Dispatchers.Default, + thunkDispatcher: CoroutineDispatcher = Dispatchers.Default, + reduceDispatcher: CoroutineDispatcher = Dispatchers.Default ) : Bloc(), BlocExtension { - // ReduceProcessor, ThunkProcessor and InitializeProcessor need to be defined in this exact - // order or we risk NullPointerExceptions + private val blocLifecycle: BlocLifecycle = BlocLifecycleImpl(blocContext.lifecycle) + + /* ****************************************************************************************** + * ReduceProcessor, ThunkProcessor and InitializeProcessor need to be defined in this exact * + * order or we risk NullPointerExceptions * + ********************************************************************************************/ private val reduceProcessor = ReduceProcessor( - blocContext, - blocState, - reducers, - reduceDispatcher + lifecycle = blocLifecycle, + state = blocState, + dispatcher = reduceDispatcher, + reducers = reducers ) private val thunkProcessor = ThunkProcessor( - blocContext, - blocState, - thunks, - thunkDispatcher, - reduceProcessor::send + lifecycle = blocLifecycle, + state = blocState, + dispatcher = thunkDispatcher, + thunks = thunks, + dispatch = reduceProcessor::send ) private val initializeProcessor = InitializeProcessor( - blocContext, - blocState, - initialize, - initDispatcher - ) { send(it) } + lifecycle = blocLifecycle, + state = blocState, + dispatcher = initDispatcher, + initializer = initialize, + dispatch = { initActionQueue += it } + ) + + /** + * Queue for actions dispatched by the initializer. + * These actions are processed once the bloc transitions to the Started state. + */ + private val initActionQueue by lazy { ArrayDeque(10) } + + private inner class ActionQueueElement( + val action: Action? = null, + val thunk: ThunkNoAction? = null, + val reducer: ReducerNoAction>? = null + ) + + /** + * Queue for thunks and reducers submitted while the bloc is being initialized (initializer is + * running). These thunks/reducers are processed once the bloc transitions to the Started state. + */ + private val actionQueue by lazy { ArrayDeque(10) } + + init { + blocLifecycle.subscribe(onStart = { + // process initializer actions first + while (! initActionQueue.isEmpty()) { + val entry = initActionQueue.removeFirst() + send(entry) + } + + // before processing action, thunks and reducers + while (! actionQueue.isEmpty()) { + val entry = actionQueue.removeFirst() + entry.action?.run(::send) + entry.thunk?.run(thunkProcessor::thunk) + entry.reducer?.run(reduceProcessor::reduce) + } + }) + } /** * The current state @@ -71,10 +114,16 @@ internal class BlocImpl actionQueue += ActionQueueElement(action = action) + + // thunks are always processed first + // ThunkProcessor will send the action to ReduceProcessor if there's no matching thunk + blocLifecycle.isStarted() -> thunkProcessor.send(action) + + else -> { /* NOP*/ } + } } /** @@ -128,7 +177,12 @@ internal class BlocImpl run a Reducer MVVM+ style */ override fun reduce(reduce: ReducerNoAction>) { - reduceProcessor.reduce(reduce) + when { + // we need to cache if the initializer is still running + blocLifecycle.isStarting() -> actionQueue += ActionQueueElement(reducer = reduce) + blocLifecycle.isStarted() -> reduceProcessor.reduce(reduce) + else -> { /* NOP*/ } + } } /** @@ -136,7 +190,12 @@ internal class BlocImpl run a thunk MVVM+ style */ override fun thunk(thunk: ThunkNoAction) { - thunkProcessor.thunk(thunk) + when { + // we need to cache if the initializer is still running + blocLifecycle.isStarting() -> actionQueue += ActionQueueElement(thunk = thunk) + blocLifecycle.isStarted() -> thunkProcessor.thunk(thunk) + else -> { /* NOP*/ } + } } } diff --git a/bloc-core/src/commonMain/kotlin/com/onegravity/bloc/internal/Coroutine.kt b/bloc-core/src/commonMain/kotlin/com/onegravity/bloc/internal/Coroutine.kt new file mode 100644 index 00000000..8d38f521 --- /dev/null +++ b/bloc-core/src/commonMain/kotlin/com/onegravity/bloc/internal/Coroutine.kt @@ -0,0 +1,31 @@ +package com.onegravity.bloc.internal + +import com.onegravity.bloc.utils.CoroutineRunner +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel + +/** + * Helper class to manage CoroutineScope and CoroutineRunner + */ +internal class Coroutine(private val dispatcher: CoroutineDispatcher) { + internal var scope: CoroutineScope? = null + private set(value) { + if (value != null) { + field = value + runner = CoroutineRunner(value) + } + } + + internal var runner: CoroutineRunner? = null + private set + + internal fun onStart() { + scope = CoroutineScope(SupervisorJob() + dispatcher) + } + + internal fun onStop() { + scope?.cancel() + } +} \ No newline at end of file diff --git a/bloc-core/src/commonMain/kotlin/com/onegravity/bloc/internal/InitializeProcessor.kt b/bloc-core/src/commonMain/kotlin/com/onegravity/bloc/internal/InitializeProcessor.kt index d5c1f63b..4df583c9 100644 --- a/bloc-core/src/commonMain/kotlin/com/onegravity/bloc/internal/InitializeProcessor.kt +++ b/bloc-core/src/commonMain/kotlin/com/onegravity/bloc/internal/InitializeProcessor.kt @@ -1,21 +1,24 @@ package com.onegravity.bloc.internal -import com.arkivanov.essenty.lifecycle.* -import com.onegravity.bloc.BlocContext +import com.onegravity.bloc.internal.lifecycle.BlocLifecycle +import com.onegravity.bloc.internal.lifecycle.subscribe import com.onegravity.bloc.state.BlocState -import com.onegravity.bloc.utils.* -import kotlinx.coroutines.* +import com.onegravity.bloc.utils.Initializer +import com.onegravity.bloc.utils.InitializerContext +import com.onegravity.bloc.utils.logger +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex -import kotlin.coroutines.CoroutineContext /** * The InitializeProcessor is responsible for processing onCreate { } blocks. */ internal class InitializeProcessor( - private val blocContext: BlocContext, - private val blocState: BlocState, - private var initialize: Initializer? = null, - private val initDispatcher: CoroutineContext = Dispatchers.Default, + private val lifecycle: BlocLifecycle, + private val state: BlocState, + dispatcher: CoroutineDispatcher = Dispatchers.Default, + private var initializer: Initializer? = null, private val dispatch: (Action) -> Unit ) { @@ -25,54 +28,57 @@ internal class InitializeProcessor( * of the bloc. */ private val mutex = Mutex() - private var coroutineScope = CoroutineScope(SupervisorJob() + initDispatcher) - set(value) { - field = value - coroutineRunner = CoroutineRunner(coroutineScope) - } - private var coroutineRunner = CoroutineRunner(coroutineScope) + private var coroutine: Coroutine = Coroutine(dispatcher) /** * This needs to come after all variable/property declarations to make sure everything is * initialized before the Bloc is started */ init { - blocContext.lifecycle.doOnCreate { - logger.d("onCreate -> initialize Bloc") - coroutineScope = CoroutineScope(SupervisorJob() + initDispatcher) - initialize?.let { runInitializer(it) } - } - blocContext.lifecycle.doOnDestroy { - logger.d("onDestroy -> destroy Bloc") - coroutineScope.cancel() - } + lifecycle.subscribe( + onCreate = { + logger.d("onCreate -> initialize Bloc") + coroutine.onStart() + lifecycle.initializerStarting() + }, + onInitialize = { + logger.d("onInitialize -> run initializer") + initializer?.apply(::runInitializer) ?:lifecycle.initializerCompleted() + }, + onDestroy = { + logger.d("onDestroy -> destroy Bloc") + coroutine.onStop() + } + ) } /** * BlocExtension interface implementation: * onCreate { } -> run an initializer MVVM+ style */ - internal fun initialize(initialize: Initializer) { - when (blocContext.lifecycle.state) { - // if onCreate() hasn't been called yet, we can't run the initializer but we can - // set the initializer if there isn't one yet - Lifecycle.State.INITIALIZED -> if (this.initialize == null) this.initialize = initialize - else -> runInitializer(initialize) + internal fun initialize(initializer: Initializer) { + if (this.initializer == null) { + this.initializer = initializer + lifecycle.initializerStarting() } } private fun runInitializer(initialize: Initializer) = - coroutineScope.launch { + coroutine.scope?.launch { if (mutex.tryLock(this@InitializeProcessor)) { - val context = InitializerContext( - state = blocState.value, - dispatch = dispatch, - runner = coroutineRunner - ) - context.initialize() + coroutine.runner?.let { runner -> + val context = InitializerContext( + state = state.value, + dispatch = dispatch, + runner = runner + ) + context.initialize() + lifecycle.initializerCompleted() + } } else { logger.e("onCreate { } can only be run once!") } } + } diff --git a/bloc-core/src/commonMain/kotlin/com/onegravity/bloc/internal/ReduceProcessor.kt b/bloc-core/src/commonMain/kotlin/com/onegravity/bloc/internal/ReduceProcessor.kt index 05207c57..9ce09338 100644 --- a/bloc-core/src/commonMain/kotlin/com/onegravity/bloc/internal/ReduceProcessor.kt +++ b/bloc-core/src/commonMain/kotlin/com/onegravity/bloc/internal/ReduceProcessor.kt @@ -1,39 +1,32 @@ package com.onegravity.bloc.internal -import com.arkivanov.essenty.lifecycle.doOnStart -import com.arkivanov.essenty.lifecycle.doOnStop -import com.onegravity.bloc.BlocContext import com.onegravity.bloc.internal.builder.MatcherReducer +import com.onegravity.bloc.internal.lifecycle.BlocLifecycle +import com.onegravity.bloc.internal.lifecycle.subscribe import com.onegravity.bloc.state.BlocState import com.onegravity.bloc.utils.* -import kotlinx.coroutines.* +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED import kotlinx.coroutines.flow.receiveAsFlow -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import kotlin.coroutines.CoroutineContext +import kotlinx.coroutines.launch /** * The ReduceProcessor is responsible for processing the reduce { }, reduceAnd { } and * sideEffect { } blocks. */ internal class ReduceProcessor( - blocContext: BlocContext, - private val blocState: BlocState, - private val reducers: List>>, - private val reduceDispatcher: CoroutineContext = Dispatchers.Default + private val lifecycle: BlocLifecycle, + private val state: BlocState, + dispatcher: CoroutineDispatcher = Dispatchers.Default, + private val reducers: List>> ) { - /** - * Mutex to ensure only one reducer runs at a time. - */ - private val mutex = Mutex() - /** * Channel for reducers to be processed (incoming) */ - private val reduceChannel = Channel>(UNLIMITED) + private val reduceChannel = Channel>(UNLIMITED) /** * Channel for side effects (outgoing) @@ -45,40 +38,31 @@ internal class ReduceProcessor = sideEffectChannel.receiveAsFlow() - private var coroutineScope = CoroutineScope(SupervisorJob() + reduceDispatcher) - set(value) { - field = value - coroutineRunner = CoroutineRunner(coroutineScope) - } - - private var coroutineRunner = CoroutineRunner(coroutineScope) + private var coroutine: Coroutine = Coroutine(dispatcher) /** * This needs to come after all variable/property declarations to make sure everything is * initialized before the Bloc is started */ init { - blocContext.lifecycle.doOnStart { - logger.d("onStart -> start Bloc") - coroutineScope = CoroutineScope(SupervisorJob() + reduceDispatcher) - processQueue() - } - - blocContext.lifecycle.doOnStop { - logger.d("onStop -> stop Bloc") - coroutineScope.cancel() - } + lifecycle.subscribe( + onStart = { + logger.d("onStart -> start Bloc") + coroutine.onStart() + processQueue() + }, + onStop = { + logger.d("onStop -> stop Bloc") + coroutine.onStop() + } + ) } private fun processQueue() { - coroutineScope.launch { + coroutine.scope?.launch { for (element in reduceChannel) { - element.action?.let { action -> - runReducers(action) - } - element.reducer?.let { reduce -> - runReducer(reduce) - } + element.action?.run(::runReducers) + element.reducer?.run(::runReducer) } } } @@ -88,7 +72,10 @@ internal class ReduceProcessor run a Reducer Redux style */ internal fun send(action: Action) { - reduceChannel.trySend(ReduceChannelElement(action)) + if (! lifecycle.isStarted()) return + + logger.d("received reducer with action ${action.trimOutput()}") + reduceChannel.trySend(ReducerContainer(action)) } /** @@ -96,13 +83,16 @@ internal class ReduceProcessor run a Reducer MVVM+ style */ internal fun reduce(reduce: ReducerNoAction>) { - reduceChannel.trySend(ReduceChannelElement(reducer = reduce)) + if (! lifecycle.isStarted()) return + + logger.d("received reducer without action") + reduceChannel.trySend(ReducerContainer(reducer = reduce)) } /** * Triggered to execute reducers with a matching Action */ - private suspend fun runReducers(action: Action) { + private fun runReducers(action: Action) { logger.d("run reducers for action ${action.trimOutput()}") getMatchingReducers(action).fold(false) { proposalEmitted, matcherReducer -> val (_, reducer, expectsProposal) = matcherReducer @@ -135,17 +125,13 @@ internal class ReduceProcessor>.runReducer( - action: Action - ) { - coroutineScope.run { - mutex.withLock { - val context = ReducerContext(blocState.value, action, coroutineRunner) - val reduce = this@runReducer - val (proposal, sideEffects) = context.reduce() - proposal?.let { blocState.send(it) } - postSideEffects(sideEffects) - } + private fun Reducer>.runReducer(action: Action) { + coroutine.runner?.let { runner -> + val context = ReducerContext(state.value, action, runner) + val reduce = this@runReducer + val (proposal, sideEffects) = context.reduce() + proposal?.let(state::send) + sideEffects.forEach(sideEffectChannel::trySend) } } @@ -153,19 +139,11 @@ internal class ReduceProcessor>) { - coroutineScope.launch { - mutex.withLock { - val context = ReducerContextNoAction(blocState.value, coroutineRunner) - val (proposal, sideEffects) = context.reduce() - proposal?.let { blocState.send(it) } - postSideEffects(sideEffects) - } - } - } - - private suspend fun postSideEffects(sideEffects: List) { - sideEffects.forEach { - sideEffectChannel.send(it) + coroutine.runner?.let { runner -> + val context = ReducerContextNoAction(state.value, runner) + val (proposal, sideEffects) = context.reduce() + proposal?.let(state::send) + sideEffects.forEach(sideEffectChannel::trySend) } } diff --git a/bloc-core/src/commonMain/kotlin/com/onegravity/bloc/internal/ReduceChannelElement.kt b/bloc-core/src/commonMain/kotlin/com/onegravity/bloc/internal/ReducerContainer.kt similarity index 77% rename from bloc-core/src/commonMain/kotlin/com/onegravity/bloc/internal/ReduceChannelElement.kt rename to bloc-core/src/commonMain/kotlin/com/onegravity/bloc/internal/ReducerContainer.kt index fa464c9c..284dc177 100644 --- a/bloc-core/src/commonMain/kotlin/com/onegravity/bloc/internal/ReduceChannelElement.kt +++ b/bloc-core/src/commonMain/kotlin/com/onegravity/bloc/internal/ReducerContainer.kt @@ -6,7 +6,7 @@ import com.onegravity.bloc.utils.ReducerNoAction /** * Wrapper class for reducers that are submitted Redux style (send(Action)) or MVVM+ style (reduce { }) */ -internal data class ReduceChannelElement( +internal data class ReducerContainer( val action: Action? = null, val reducer: ReducerNoAction>? = null ) diff --git a/bloc-core/src/commonMain/kotlin/com/onegravity/bloc/internal/ThunkProcessor.kt b/bloc-core/src/commonMain/kotlin/com/onegravity/bloc/internal/ThunkProcessor.kt index 749e72fc..b8dad0b9 100644 --- a/bloc-core/src/commonMain/kotlin/com/onegravity/bloc/internal/ThunkProcessor.kt +++ b/bloc-core/src/commonMain/kotlin/com/onegravity/bloc/internal/ThunkProcessor.kt @@ -1,25 +1,23 @@ package com.onegravity.bloc.internal -import com.arkivanov.essenty.lifecycle.doOnStart -import com.arkivanov.essenty.lifecycle.doOnStop -import com.onegravity.bloc.BlocContext import com.onegravity.bloc.internal.builder.MatcherThunk +import com.onegravity.bloc.internal.lifecycle.BlocLifecycle +import com.onegravity.bloc.internal.lifecycle.subscribe import com.onegravity.bloc.state.BlocState import com.onegravity.bloc.utils.* import kotlinx.coroutines.* import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED -import kotlin.coroutines.CoroutineContext /** * The ThunkProcessor is responsible for processing thunk { } blocks. */ internal class ThunkProcessor( - blocContext: BlocContext, - private val blocState: BlocState, + private val lifecycle: BlocLifecycle, + private val state: BlocState, + dispatcher: CoroutineDispatcher = Dispatchers.Default, private val thunks: List> = emptyList(), - private val thunkDispatcher: CoroutineContext = Dispatchers.Default, - private val runReducers: (action: Action) -> Unit + private val dispatch: (action: Action) -> Unit ) { /** @@ -27,31 +25,26 @@ internal class ThunkProcessor( */ private val thunkChannel = Channel(UNLIMITED) - private var coroutineScope = CoroutineScope(SupervisorJob() + thunkDispatcher) - set(value) { - field = value - coroutineRunner = CoroutineRunner(coroutineScope) - } - - private var coroutineRunner = CoroutineRunner(coroutineScope) + private var coroutine: Coroutine = Coroutine(dispatcher) /** * This needs to come after all variable/property declarations to make sure everything is * initialized before the Bloc is started */ init { - blocContext.lifecycle.doOnStart { - coroutineScope = CoroutineScope(SupervisorJob() + thunkDispatcher) - processQueue() - } - - blocContext.lifecycle.doOnStop { - coroutineScope.cancel() - } + lifecycle.subscribe( + onStart = { + coroutine.onStart() + processQueue() + }, + onStop = { + coroutine.onStop() + } + ) } private fun processQueue() { - coroutineScope.launch { + coroutine.scope?.launch { for (action in thunkChannel) { runThunks(action) } @@ -63,10 +56,13 @@ internal class ThunkProcessor( * thunk { } -> run a thunk Redux style */ internal fun send(action: Action) { + if (! lifecycle.isStarted()) return + + logger.d("received thunk with action ${action.trimOutput()}") if (thunks.any { it.matcher == null || it.matcher.matches(action) }) { thunkChannel.trySend(action) } else { - runReducers(action) + dispatch(action) } } @@ -74,18 +70,24 @@ internal class ThunkProcessor( * BlocExtension interface implementation: * thunk { } -> run a thunk MVVM+ style */ - internal fun thunk(thunk: ThunkNoAction) = - coroutineScope.launch { - val dispatcher: Dispatcher = { - nextThunkDispatcher(it).invoke(it) + internal fun thunk(thunk: ThunkNoAction) { + // we don't need to check if (lifecycle.state == LifecycleState.Started) since the + // CoroutineScope is cancelled onStop() + coroutine.scope?.launch { + coroutine.runner?.let { runner -> + logger.d("received thunk without action") + val dispatcher: Dispatcher = { + nextThunkDispatcher(it).invoke(it) + } + val context = ThunkContextNoAction( + getState = { state.value }, + dispatch = dispatcher, + runner = runner + ) + context.thunk() } - val context = ThunkContextNoAction( - getState = { blocState.value }, - dispatch = dispatcher, - runner = coroutineRunner - ) - context.thunk() } + } /** * Run all matching thunks @@ -107,18 +109,20 @@ internal class ThunkProcessor( * Run a specific thunk */ private suspend fun runThunk(action: Action, index: Int) { - coroutineScope.run { - val dispatcher: Dispatcher = { - nextThunkDispatcher(it, index + 1).invoke(it) + coroutine.scope.run { + coroutine.runner?.let { runner -> + val dispatcher: Dispatcher = { + nextThunkDispatcher(it, index + 1).invoke(it) + } + val thunk = thunks[index].thunk + val context = ThunkContext( + getState = { state.value }, + action = action, + dispatch = dispatcher, + runner = runner + ) + context.thunk() } - val thunk = thunks[index].thunk - val context = ThunkContext( - getState = { blocState.value }, - action = action, - dispatch = dispatcher, - runner = coroutineRunner - ) - context.thunk() } } @@ -133,7 +137,7 @@ internal class ThunkProcessor( } } - return { runReducers(action) } + return { dispatch(action) } } } diff --git a/bloc-core/src/commonMain/kotlin/com/onegravity/bloc/internal/builder/BlocBuilder.kt b/bloc-core/src/commonMain/kotlin/com/onegravity/bloc/internal/builder/BlocBuilder.kt index 8dd6fb6a..6fee82e7 100644 --- a/bloc-core/src/commonMain/kotlin/com/onegravity/bloc/internal/builder/BlocBuilder.kt +++ b/bloc-core/src/commonMain/kotlin/com/onegravity/bloc/internal/builder/BlocBuilder.kt @@ -3,10 +3,11 @@ package com.onegravity.bloc.internal.builder import com.onegravity.bloc.Bloc import com.onegravity.bloc.BlocContext import com.onegravity.bloc.internal.BlocImpl +import com.onegravity.bloc.internal.fsm.Matcher import com.onegravity.bloc.state.BlocState import com.onegravity.bloc.utils.* +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers -import kotlin.coroutines.CoroutineContext import kotlin.jvm.JvmName /** @@ -21,9 +22,9 @@ public class BlocBuilder { private var _initialize: Initializer? = null private val _thunks = ArrayList>() private val _reducers = ArrayList>>() - private var _initDispatcher: CoroutineContext = Dispatchers.Default - private var _thunkDispatcher: CoroutineContext = Dispatchers.Default - private var _reduceDispatcher: CoroutineContext = Dispatchers.Default + private var _initDispatcher: CoroutineDispatcher = Dispatchers.Default + private var _thunkDispatcher: CoroutineDispatcher = Dispatchers.Default + private var _reduceDispatcher: CoroutineDispatcher = Dispatchers.Default internal fun build( context: BlocContext, @@ -242,7 +243,7 @@ public class BlocBuilder { /* Dispatcher */ @BlocDSL - public var dispatchers: CoroutineContext = Dispatchers.Default + public var dispatchers: CoroutineDispatcher = Dispatchers.Default set(value) { field = value _initDispatcher = value @@ -251,21 +252,21 @@ public class BlocBuilder { } @BlocDSL - public var initDispatcher: CoroutineContext = Dispatchers.Default + public var initDispatcher: CoroutineDispatcher = Dispatchers.Default set(value) { field = value _initDispatcher = value } @BlocDSL - public var thunkDispatcher: CoroutineContext = Dispatchers.Default + public var thunkDispatcher: CoroutineDispatcher = Dispatchers.Default set(value) { field = value _thunkDispatcher = value } @BlocDSL - public var reduceDispatcher: CoroutineContext = Dispatchers.Default + public var reduceDispatcher: CoroutineDispatcher = Dispatchers.Default set(value) { field = value _reduceDispatcher = value diff --git a/bloc-core/src/commonMain/kotlin/com/onegravity/bloc/internal/builder/MatcherReducer.kt b/bloc-core/src/commonMain/kotlin/com/onegravity/bloc/internal/builder/MatcherReducer.kt index 12be936b..d1919237 100644 --- a/bloc-core/src/commonMain/kotlin/com/onegravity/bloc/internal/builder/MatcherReducer.kt +++ b/bloc-core/src/commonMain/kotlin/com/onegravity/bloc/internal/builder/MatcherReducer.kt @@ -1,5 +1,6 @@ package com.onegravity.bloc.internal.builder +import com.onegravity.bloc.internal.fsm.Matcher import com.onegravity.bloc.utils.Reducer internal data class MatcherReducer( diff --git a/bloc-core/src/commonMain/kotlin/com/onegravity/bloc/internal/builder/MatcherThunk.kt b/bloc-core/src/commonMain/kotlin/com/onegravity/bloc/internal/builder/MatcherThunk.kt index 31f3a329..1c0f945c 100644 --- a/bloc-core/src/commonMain/kotlin/com/onegravity/bloc/internal/builder/MatcherThunk.kt +++ b/bloc-core/src/commonMain/kotlin/com/onegravity/bloc/internal/builder/MatcherThunk.kt @@ -1,5 +1,6 @@ package com.onegravity.bloc.internal.builder +import com.onegravity.bloc.internal.fsm.Matcher import com.onegravity.bloc.utils.Thunk internal data class MatcherThunk( diff --git a/bloc-core/src/commonMain/kotlin/com/onegravity/bloc/internal/fsm/Graph.kt b/bloc-core/src/commonMain/kotlin/com/onegravity/bloc/internal/fsm/Graph.kt new file mode 100644 index 00000000..21ebbfd9 --- /dev/null +++ b/bloc-core/src/commonMain/kotlin/com/onegravity/bloc/internal/fsm/Graph.kt @@ -0,0 +1,21 @@ +/** From https://github.com/Tinder/StateMachine */ + +package com.onegravity.bloc.internal.fsm + +internal data class Graph( + val initialState: STATE, + val stateDefinitions: Map, State>, + val onTransitionListeners: List<(Transition) -> Unit> +) { + + internal class State internal constructor() { + internal val onEnterListeners = mutableListOf<(STATE, EVENT) -> Unit>() + internal val onExitListeners = mutableListOf<(STATE, EVENT) -> Unit>() + internal val transitions = linkedMapOf, (STATE, EVENT) -> TransitionTo>() + + internal data class TransitionTo internal constructor( + val toState: STATE, + val sideEffect: SIDE_EFFECT? + ) + } +} diff --git a/bloc-core/src/commonMain/kotlin/com/onegravity/bloc/internal/fsm/GraphBuilder.kt b/bloc-core/src/commonMain/kotlin/com/onegravity/bloc/internal/fsm/GraphBuilder.kt new file mode 100644 index 00000000..299a6d35 --- /dev/null +++ b/bloc-core/src/commonMain/kotlin/com/onegravity/bloc/internal/fsm/GraphBuilder.kt @@ -0,0 +1,42 @@ +/** From https://github.com/Tinder/StateMachine */ + +package com.onegravity.bloc.internal.fsm + +internal class GraphBuilder( + graph: Graph? = null +) { + private var initialState = graph?.initialState + private val stateDefinitions = LinkedHashMap(graph?.stateDefinitions ?: emptyMap()) + private val onTransitionListeners = ArrayList(graph?.onTransitionListeners ?: emptyList()) + + internal fun initialState(initialState: STATE) { + this.initialState = initialState + } + + internal fun state( + stateMatcher: Matcher, + init: StateDefinitionBuilder.() -> Unit + ) { + stateDefinitions[stateMatcher] = StateDefinitionBuilder().apply(init).build() + } + + internal inline fun state(noinline init: StateDefinitionBuilder.() -> Unit) { + state(Matcher.any(), init) + } + + internal inline fun state(state: S, noinline init: StateDefinitionBuilder.() -> Unit) { + state(Matcher.eq(state), init) + } + + internal fun onTransition(listener: (Transition) -> Unit) { + onTransitionListeners.add(listener) + } + + internal fun build(): Graph { + return Graph( + requireNotNull(initialState), + stateDefinitions.toMap(), + onTransitionListeners.toList() + ) + } +} diff --git a/bloc-core/src/commonMain/kotlin/com/onegravity/bloc/internal/builder/Matcher.kt b/bloc-core/src/commonMain/kotlin/com/onegravity/bloc/internal/fsm/Matcher.kt similarity index 98% rename from bloc-core/src/commonMain/kotlin/com/onegravity/bloc/internal/builder/Matcher.kt rename to bloc-core/src/commonMain/kotlin/com/onegravity/bloc/internal/fsm/Matcher.kt index e57f2f30..63bf4b88 100644 --- a/bloc-core/src/commonMain/kotlin/com/onegravity/bloc/internal/builder/Matcher.kt +++ b/bloc-core/src/commonMain/kotlin/com/onegravity/bloc/internal/fsm/Matcher.kt @@ -1,6 +1,6 @@ /** From https://github.com/Tinder/StateMachine */ -package com.onegravity.bloc.internal.builder +package com.onegravity.bloc.internal.fsm import kotlin.reflect.KClass diff --git a/bloc-core/src/commonMain/kotlin/com/onegravity/bloc/internal/fsm/StateDefinitionBuilder.kt b/bloc-core/src/commonMain/kotlin/com/onegravity/bloc/internal/fsm/StateDefinitionBuilder.kt new file mode 100644 index 00000000..60ead856 --- /dev/null +++ b/bloc-core/src/commonMain/kotlin/com/onegravity/bloc/internal/fsm/StateDefinitionBuilder.kt @@ -0,0 +1,58 @@ +/** From https://github.com/Tinder/StateMachine */ + +package com.onegravity.bloc.internal.fsm + +internal class StateDefinitionBuilder { + + private val stateDefinition = Graph.State() + + internal inline fun any(): Matcher = Matcher.any() + + internal inline fun eq(value: R): Matcher = Matcher.eq(value) + + internal fun on( + eventMatcher: Matcher, + createTransitionTo: S.(E) -> Graph.State.TransitionTo + ) { + stateDefinition.transitions[eventMatcher] = { state, event -> + @Suppress("UNCHECKED_CAST") + createTransitionTo((state as S), event as E) + } + } + + internal inline fun on( + noinline createTransitionTo: S.(E) -> Graph.State.TransitionTo + ) { + return on(any(), createTransitionTo) + } + + internal inline fun on( + event: E, + noinline createTransitionTo: S.(E) -> Graph.State.TransitionTo + ) { + return on(eq(event), createTransitionTo) + } + + internal fun onEnter(listener: S.(EVENT) -> Unit) = with(stateDefinition) { + onEnterListeners.add { state, cause -> + @Suppress("UNCHECKED_CAST") + listener(state as S, cause) + } + } + + internal fun onExit(listener: S.(EVENT) -> Unit) = with(stateDefinition) { + onExitListeners.add { state, cause -> + @Suppress("UNCHECKED_CAST") + listener(state as S, cause) + } + } + + internal fun build() = stateDefinition + + @Suppress("UNUSED") // The unused warning is probably a compiler bug. + internal fun S.transitionTo(state: STATE, sideEffect: SIDE_EFFECT? = null) = + Graph.State.TransitionTo(state, sideEffect) + + @Suppress("UNUSED") // The unused warning is probably a compiler bug. + internal fun S.dontTransition(sideEffect: SIDE_EFFECT? = null) = transitionTo(this, sideEffect) +} diff --git a/bloc-core/src/commonMain/kotlin/com/onegravity/bloc/internal/fsm/StateMachine.kt b/bloc-core/src/commonMain/kotlin/com/onegravity/bloc/internal/fsm/StateMachine.kt new file mode 100644 index 00000000..740970e3 --- /dev/null +++ b/bloc-core/src/commonMain/kotlin/com/onegravity/bloc/internal/fsm/StateMachine.kt @@ -0,0 +1,90 @@ +/** From https://github.com/Tinder/StateMachine */ + +package com.onegravity.bloc.internal.fsm + +import kotlinx.atomicfu.atomic +import kotlinx.atomicfu.locks.SynchronizedObject +import kotlinx.atomicfu.locks.synchronized + +internal class StateMachine private constructor( + private val graph: Graph +) { + + private val lock = SynchronizedObject() + private val stateRef = atomic(graph.initialState) + + var state: STATE + get() = stateRef.value + private set(value) { + stateRef.value = value + } + + internal fun transition(event: EVENT): Transition { + val transition = synchronized(lock) { + val fromState = state + val transition = fromState.getTransition(event) + if (transition is Transition.Valid) { + state = transition.toState + } + transition + } + transition.notifyOnTransition() + if (transition is Transition.Valid) { + with(transition) { + with(fromState) { + notifyOnExit(event) + } + with(toState) { + notifyOnEnter(event) + } + } + } + return transition + } + + internal fun with(init: GraphBuilder.() -> Unit): StateMachine { + return create(graph.copy(initialState = state), init) + } + + private fun STATE.getTransition(event: EVENT): Transition { + for ((eventMatcher, createTransitionTo) in getDefinition().transitions) { + if (eventMatcher.matches(event)) { + val (toState, sideEffect) = createTransitionTo(this, event) + return Transition.Valid(this, event, toState, sideEffect) + } + } + return Transition.Invalid(this, event) + } + + private fun STATE.getDefinition() = graph.stateDefinitions + .filter { it.key.matches(this) } + .map { it.value } + .firstOrNull() ?: error("Missing definition for state $this!") + + private fun STATE.notifyOnEnter(cause: EVENT) { + getDefinition().onEnterListeners.forEach { it(this, cause) } + } + + private fun STATE.notifyOnExit(cause: EVENT) { + getDefinition().onExitListeners.forEach { it(this, cause) } + } + + private fun Transition.notifyOnTransition() { + graph.onTransitionListeners.forEach { it(this) } + } + + companion object { + internal fun create( + init: GraphBuilder.() -> Unit + ): StateMachine { + return create(null, init) + } + + private fun create( + graph: Graph?, + init: GraphBuilder.() -> Unit + ): StateMachine { + return StateMachine(GraphBuilder(graph).apply(init).build()) + } + } +} \ No newline at end of file diff --git a/bloc-core/src/commonMain/kotlin/com/onegravity/bloc/internal/fsm/Transition.kt b/bloc-core/src/commonMain/kotlin/com/onegravity/bloc/internal/fsm/Transition.kt new file mode 100644 index 00000000..da14b47c --- /dev/null +++ b/bloc-core/src/commonMain/kotlin/com/onegravity/bloc/internal/fsm/Transition.kt @@ -0,0 +1,21 @@ +/** From https://github.com/Tinder/StateMachine */ + +package com.onegravity.bloc.internal.fsm + +@Suppress("UNUSED") +internal sealed class Transition { + abstract val fromState: STATE + abstract val event: EVENT + + internal data class Valid internal constructor( + override val fromState: STATE, + override val event: EVENT, + val toState: STATE, + val sideEffect: SIDE_EFFECT? + ) : Transition() + + internal data class Invalid internal constructor( + override val fromState: STATE, + override val event: EVENT + ) : Transition() +} \ No newline at end of file diff --git a/bloc-core/src/commonMain/kotlin/com/onegravity/bloc/internal/lifecycle/BlocLifecycle.kt b/bloc-core/src/commonMain/kotlin/com/onegravity/bloc/internal/lifecycle/BlocLifecycle.kt new file mode 100644 index 00000000..71d9bc48 --- /dev/null +++ b/bloc-core/src/commonMain/kotlin/com/onegravity/bloc/internal/lifecycle/BlocLifecycle.kt @@ -0,0 +1,17 @@ +package com.onegravity.bloc.internal.lifecycle + +internal interface BlocLifecycle { + + fun subscribe(callbacks: Callbacks) + + fun unsubscribe(callbacks: Callbacks) + + fun initializerStarting() + + fun initializerCompleted() + + fun isStarted(): Boolean + + fun isStarting(): Boolean + +} \ No newline at end of file diff --git a/bloc-core/src/commonMain/kotlin/com/onegravity/bloc/internal/lifecycle/BlocLifecycleImpl.kt b/bloc-core/src/commonMain/kotlin/com/onegravity/bloc/internal/lifecycle/BlocLifecycleImpl.kt new file mode 100644 index 00000000..03b22280 --- /dev/null +++ b/bloc-core/src/commonMain/kotlin/com/onegravity/bloc/internal/lifecycle/BlocLifecycleImpl.kt @@ -0,0 +1,74 @@ +package com.onegravity.bloc.internal.lifecycle + +import com.arkivanov.essenty.lifecycle.* +import com.onegravity.bloc.internal.lifecycle.LifecycleState.* + +/** + * BlocLifecycle tied to an Essenty Lifecycle which is the "external" lifecycle a Bloc follows. + * The BlocLifecycle is implemented using a finite state machine under the hood. + */ +internal class BlocLifecycleImpl(essentyLifecycle: Lifecycle) : BlocLifecycle { + + private var callbacks = emptySet() + + private val stateMachine = LifecycleStateMachine { transition -> + // translate BlocLifecycle lifecycle events to Callbacks + when (transition.to) { + Created -> callbacks.forEach(Callbacks::onCreate) + .also { callbackSet += CallbackElement.OnCreate } + Initializing -> callbacks.forEach(Callbacks::onInitialize) + .also { callbackSet += CallbackElement.OnInitialize } + InitializingStarting -> if (transition.from == Starting) callbacks.forEach(Callbacks::onInitialize) + .also { callbackSet += CallbackElement.OnInitialize } + Started -> callbacks.forEach(Callbacks::onStart) + .also { callbackSet += CallbackElement.OnStart } + Stopped -> callbacks.forEach(Callbacks::onStop) + .also { callbackSet += CallbackElement.OnStop } + Destroyed -> callbacks.forEach(Callbacks::onDestroy) + .also { callbackSet -= CallbackElement.OnDestroy } + else -> { /* NOP */ } + } + } + + private enum class CallbackElement { + OnCreate, OnInitialize, OnStart, OnStop, OnDestroy + } + private val callbackSet = HashSet() + + init { + // translate Essenty lifecycle to BlocLifecycle events + essentyLifecycle.doOnCreate { stateMachine.transition(LifecycleEvent.Create) } + essentyLifecycle.doOnStart { stateMachine.transition(LifecycleEvent.Start) } + essentyLifecycle.doOnStop { stateMachine.transition(LifecycleEvent.Stop) } + essentyLifecycle.doOnDestroy { stateMachine.transition(LifecycleEvent.Destroy) } + } + + override fun subscribe(callbacks: Callbacks) { + check(callbacks !in this.callbacks) { "Already subscribed" } + + if (callbackSet.contains(CallbackElement.OnCreate)) callbacks.onCreate() + if (callbackSet.contains(CallbackElement.OnInitialize)) callbacks.onInitialize() + if (callbackSet.contains(CallbackElement.OnStart)) callbacks.onStart() + if (callbackSet.contains(CallbackElement.OnStop)) callbacks.onStop() + if (callbackSet.contains(CallbackElement.OnDestroy)) callbacks.onDestroy() + + this.callbacks += callbacks + } + + override fun unsubscribe(callbacks: Callbacks) { + this.callbacks -= callbacks + } + + override fun initializerStarting() { + stateMachine.transition(LifecycleEvent.StartInitializer) + } + + override fun initializerCompleted() { + stateMachine.transition(LifecycleEvent.InitializerCompleted) + } + + override fun isStarted() = stateMachine.state == Started + + override fun isStarting() = stateMachine.state.let { it == InitializingStarting || it == Initializing } + +} diff --git a/bloc-core/src/commonMain/kotlin/com/onegravity/bloc/internal/lifecycle/Callbacks.kt b/bloc-core/src/commonMain/kotlin/com/onegravity/bloc/internal/lifecycle/Callbacks.kt new file mode 100644 index 00000000..90b88c98 --- /dev/null +++ b/bloc-core/src/commonMain/kotlin/com/onegravity/bloc/internal/lifecycle/Callbacks.kt @@ -0,0 +1,13 @@ +package com.onegravity.bloc.internal.lifecycle + +internal interface Callbacks { + fun onCreate() {} + + fun onInitialize() {} + + fun onStart() {} + + fun onStop() {} + + fun onDestroy() {} +} \ No newline at end of file diff --git a/bloc-core/src/commonMain/kotlin/com/onegravity/bloc/internal/lifecycle/LifecycleEvent.kt b/bloc-core/src/commonMain/kotlin/com/onegravity/bloc/internal/lifecycle/LifecycleEvent.kt new file mode 100644 index 00000000..7d3bffbc --- /dev/null +++ b/bloc-core/src/commonMain/kotlin/com/onegravity/bloc/internal/lifecycle/LifecycleEvent.kt @@ -0,0 +1,10 @@ +package com.onegravity.bloc.internal.lifecycle + +internal sealed class LifecycleEvent { + object Create : LifecycleEvent() + object StartInitializer : LifecycleEvent() + object InitializerCompleted : LifecycleEvent() + object Start : LifecycleEvent() + object Stop : LifecycleEvent() + object Destroy : LifecycleEvent() +} \ No newline at end of file diff --git a/bloc-core/src/commonMain/kotlin/com/onegravity/bloc/internal/lifecycle/LifecycleExtensions.kt b/bloc-core/src/commonMain/kotlin/com/onegravity/bloc/internal/lifecycle/LifecycleExtensions.kt new file mode 100644 index 00000000..bf7bd828 --- /dev/null +++ b/bloc-core/src/commonMain/kotlin/com/onegravity/bloc/internal/lifecycle/LifecycleExtensions.kt @@ -0,0 +1,29 @@ +package com.onegravity.bloc.internal.lifecycle + +internal fun BlocLifecycle.subscribe( + onCreate: (() -> Unit)? = null, + onInitialize: (() -> Unit)? = null, + onStart: (() -> Unit)? = null, + onStop: (() -> Unit)? = null, + onDestroy: (() -> Unit)? = null +) = object : Callbacks { + override fun onCreate() { + onCreate?.invoke() + } + + override fun onInitialize() { + onInitialize?.invoke() + } + + override fun onStart() { + onStart?.invoke() + } + + override fun onStop() { + onStop?.invoke() + } + + override fun onDestroy() { + onDestroy?.invoke() + } +}.also(::subscribe) diff --git a/bloc-core/src/commonMain/kotlin/com/onegravity/bloc/internal/lifecycle/LifecycleState.kt b/bloc-core/src/commonMain/kotlin/com/onegravity/bloc/internal/lifecycle/LifecycleState.kt new file mode 100644 index 00000000..92a12259 --- /dev/null +++ b/bloc-core/src/commonMain/kotlin/com/onegravity/bloc/internal/lifecycle/LifecycleState.kt @@ -0,0 +1,15 @@ +package com.onegravity.bloc.internal.lifecycle + +internal sealed class LifecycleState(private val name: String) { + object InitialState : LifecycleState("InitialState") + object Created : LifecycleState("Created") + object Initializing : LifecycleState("Initializing") + object Initialized : LifecycleState("Initialized") + object InitializingStarting : LifecycleState("InitializingStarting") + object Starting : LifecycleState("Starting") + object Started : LifecycleState("Started") + object Stopped : LifecycleState("Stopped") + object Destroyed : LifecycleState("Destroyed") + + override fun toString() = name +} diff --git a/bloc-core/src/commonMain/kotlin/com/onegravity/bloc/internal/lifecycle/LifecycleStateMachine.kt b/bloc-core/src/commonMain/kotlin/com/onegravity/bloc/internal/lifecycle/LifecycleStateMachine.kt new file mode 100644 index 00000000..14b757c9 --- /dev/null +++ b/bloc-core/src/commonMain/kotlin/com/onegravity/bloc/internal/lifecycle/LifecycleStateMachine.kt @@ -0,0 +1,199 @@ +package com.onegravity.bloc.internal.lifecycle + +import com.onegravity.bloc.internal.fsm.StateMachine +import com.onegravity.bloc.internal.fsm.Transition +import com.onegravity.bloc.utils.logger + +internal data class LifecycleTransition(val from: LifecycleState, val to: LifecycleState) +internal typealias LifecycleSideEffect = List + +@Suppress("FunctionName", "RemoveExplicitTypeArguments") +internal fun LifecycleStateMachine( + observer: (transition: LifecycleTransition) -> Unit, +) = StateMachine.create { + + initialState(LifecycleState.InitialState) + + state { + on { + transitionTo(LifecycleState.Created) + } + } + + state { + on { + logger.e("a created Bloc can't be created twice") + dontTransition() + } + on { + transitionTo(LifecycleState.Initializing) + } + on { + transitionTo(LifecycleState.Starting) + } + on { + logger.e("a Bloc must be started before it can be stopped") + dontTransition() + } + on { + transitionTo(LifecycleState.Destroyed) + } + } + + state { + on { + logger.e("a starting Bloc can't be started twice") + dontTransition() + } + on { + transitionTo(LifecycleState.InitializingStarting) + } + on { + transitionTo(LifecycleState.Stopped) + } + on { + transitionTo(LifecycleState.Destroyed, listOf(LifecycleState.Stopped, LifecycleState.Destroyed)) + } + } + + state { + on { + logger.e("Initializer already running") + dontTransition() + } + on { + transitionTo(LifecycleState.Initialized) + } + on { + transitionTo(LifecycleState.InitializingStarting) + } + on { + transitionTo(LifecycleState.Stopped) + } + on { + transitionTo(LifecycleState.Destroyed, listOf(LifecycleState.Stopped, LifecycleState.Destroyed)) + } + } + + state { + on { + logger.e("Initializer already completed") + dontTransition() + } + on { + logger.e("Initializer already completed") + dontTransition() + } + on { + transitionTo(LifecycleState.Started) + } + on { + transitionTo(LifecycleState.Stopped) + } + on { + transitionTo(LifecycleState.Destroyed, listOf(LifecycleState.Stopped, LifecycleState.Destroyed)) + } + } + + state { + on { + transitionTo(LifecycleState.Started) + } + on { + logger.e("Initializer already completed") + dontTransition() + } + on { + logger.e("a starting Bloc can't be started twice") + dontTransition() + } + on { + transitionTo(LifecycleState.Stopped) + } + on { + transitionTo(LifecycleState.Destroyed, listOf(LifecycleState.Stopped, LifecycleState.Destroyed)) + } + } + + state { + on { + logger.e("a started Bloc can't be started twice") + dontTransition() + } + on { + logger.e("Can't start initializer if the bloc is already started") + dontTransition() + } + on { + transitionTo(LifecycleState.Stopped) + } + on { + transitionTo( + LifecycleState.Destroyed, + listOf(LifecycleState.Started, LifecycleState.Stopped, LifecycleState.Destroyed) + ) + } + } + + state { + on { + logger.e("Initializer already completed") + dontTransition() + } + on { + transitionTo(LifecycleState.Started) + } + on { + logger.e("a stopped Bloc can't be stopped twice") + dontTransition() + } + on { + transitionTo(LifecycleState.Destroyed) + } + } + + state { + // this is the final state -> no transitions + on { + logger.e("Can't create an already destroyed Bloc") + dontTransition() + } + on { + logger.e("Can't start initializer for an already destroyed Bloc") + dontTransition() + } + on { + logger.e("Initializer completed for an already destroyed Bloc -> we should investigate for race conditions") + dontTransition() + } + on { + logger.e("Can't start an already destroyed Bloc") + dontTransition() + } + on { + logger.e("Can't stop an already destroyed Bloc") + dontTransition() + } + on { + logger.e("Can't destroy a Bloc twice") + dontTransition() + } + } + + onTransition { + val validTransition = it as? Transition.Valid ?: return@onTransition + if (validTransition.fromState == validTransition.toState) return@onTransition + + logger.d("transition from ${validTransition.fromState} to ${validTransition.toState}") + + // if the transition has side effects (a list of states), we emit all transitions one by one + validTransition.sideEffect + // the transition has a side effect -> emit all transitions one by one + ?.reduceOrNull { fromState, toState -> + observer(LifecycleTransition(fromState, toState)) + toState + } + // the transition has no side effect -> emit a single transition + ?: observer(LifecycleTransition(validTransition.fromState, validTransition.toState)) + } +} diff --git a/bloc-core/src/commonMain/kotlin/com/onegravity/bloc/utils/Definitions.kt b/bloc-core/src/commonMain/kotlin/com/onegravity/bloc/utils/Definitions.kt index 03395ca5..4bfea97c 100644 --- a/bloc-core/src/commonMain/kotlin/com/onegravity/bloc/utils/Definitions.kt +++ b/bloc-core/src/commonMain/kotlin/com/onegravity/bloc/utils/Definitions.kt @@ -55,11 +55,11 @@ public typealias GetState = () -> State public typealias ThunkNoAction = suspend ThunkContextNoAction.() -> Unit -public typealias Reducer = suspend ReducerContext.() -> Proposal +public typealias Reducer = ReducerContext.() -> Proposal public typealias CoroutineBlock = suspend CoroutineScope.() -> Unit -public typealias ReducerNoAction = suspend ReducerContextNoAction.() -> Proposal +public typealias ReducerNoAction = ReducerContextNoAction.() -> Proposal public typealias SideEffect = ReducerContext.() -> SideEffect diff --git a/bloc-core/src/commonTest/kotlin/com/onegravity/bloc/internal/BlocInitializerExecutionTests.kt b/bloc-core/src/commonTest/kotlin/com/onegravity/bloc/internal/BlocInitializerExecutionTests.kt index 6a258c44..8f951f30 100644 --- a/bloc-core/src/commonTest/kotlin/com/onegravity/bloc/internal/BlocInitializerExecutionTests.kt +++ b/bloc-core/src/commonTest/kotlin/com/onegravity/bloc/internal/BlocInitializerExecutionTests.kt @@ -2,13 +2,17 @@ package com.onegravity.bloc.internal import com.arkivanov.essenty.lifecycle.LifecycleRegistry import com.onegravity.bloc.* +import kotlinx.coroutines.delay import kotlin.test.Test import kotlin.test.assertEquals class BlocInitializerExecutionTests : BaseTestClass() { + /** + * Test regular initializer and make sure MVVM+ style initializer don't run + */ @Test - fun testInitializerExecution1() = runTests { + fun testInitializerExecutionReduxStyle() = runTests { val lifecycleRegistry = LifecycleRegistry() val context = BlocContextImpl(lifecycleRegistry) @@ -22,50 +26,133 @@ class BlocInitializerExecutionTests : BaseTestClass() { assertEquals(1, bloc.value) - // will be ignored because 1) onCreate() hasn't been called yet and another initializer was - // already defined - bloc.onCreate { dispatch(Decrement) } - testState(bloc, null, 1) - lifecycleRegistry.onCreate() testState(bloc, null, 1) lifecycleRegistry.onStart() testState(bloc, null, 2) - // again ignored, initializer already ran - bloc.onCreate { dispatch(Decrement) } - testState(bloc, null, 2) + lifecycleRegistry.onStop() + lifecycleRegistry.onDestroy() + } + + /** + * Test whether the bloc waits for the initializer to finish before transitioning to started + * which will start processing dispatched actions. + */ + @Test + fun testInitializerExecutionDelayed() = runTests { + val lifecycleRegistry = LifecycleRegistry() + val context = BlocContextImpl(lifecycleRegistry) + + val bloc = bloc(context, 5) { + onCreate { + delay(1000) + dispatch(Increment) + } + reduce { state + 1 } + reduce { state + 5 } + } + + assertEquals(5, bloc.value) + + lifecycleRegistry.onCreate() + assertEquals(5, bloc.value) + + // the initializer is still running -> the dispatched action has no effect + lifecycleRegistry.onStart() + testState(bloc, Whatever, 5) + + delay(1050) + assertEquals(11, bloc.value) lifecycleRegistry.onStop() lifecycleRegistry.onDestroy() } + /** + * Test whether long running initializers still run before everything else + */ @Test - fun testInitializerExecution2() = runTests { + fun testInitializerExecutionOrder() = runTests { val lifecycleRegistry = LifecycleRegistry() val context = BlocContextImpl(lifecycleRegistry) - val bloc = bloc(context, 1) { + val bloc = bloc(context, 5) { + onCreate { + delay(1000) + dispatch(Increment) + } + thunk { + dispatch(Whatever) + } + thunk { + dispatch(Decrement) + } reduce { state + 1 } reduce { state - 1 } reduce { state + 5 } } - assertEquals(1, bloc.value) + assertEquals(5, bloc.value) + + // the bloc needs to wait for the initializer to finish before processing the sent action + testCollectState( + bloc, + listOf(5, 10, 15, 14, 19) + ) { + lifecycleRegistry.onCreate() + lifecycleRegistry.onStart() + bloc.send(Whatever) + bloc.send(Decrement) + bloc.send(Increment) + delay(1050) + } - bloc.onCreate { dispatch(Whatever) } + lifecycleRegistry.onStop() + lifecycleRegistry.onDestroy() + } - lifecycleRegistry.onCreate() - // initializer ran but the reducers it triggered (dispatch) are still not running - testState(bloc, null, 1) + /** + * Test whether long running initializers still run before everything else, + * now for MVVM+ style reducers and thunks + */ + @Test + fun testInitializerExecutionOrderMVVM() = runTests { + val lifecycleRegistry = LifecycleRegistry() + val context = BlocContextImpl(lifecycleRegistry) - lifecycleRegistry.onStart() - testState(bloc, null, 6) + val bloc = bloc(context, 5) { + onCreate { + delay(1000) + dispatch(Increment) + } + reduce { state + 1 } + reduce { state - 1 } + reduce { state + 5 } + } - // ignored, initializer already ran - bloc.onCreate { dispatch(Whatever) } - testState(bloc, null, 6) + assertEquals(5, bloc.value) + + // the bloc needs to wait for the initializer to finish before processing the sent action + testCollectState( + bloc, + listOf(5, 6, 5, 15, 20, 19, 24, 25) + ) { + lifecycleRegistry.onCreate() + lifecycleRegistry.onStart() + bloc.reduce { state - 1 } + bloc.reduce { state + 10 } + bloc.reduce { state + 5 } + bloc.thunk { dispatch(Decrement) } + bloc.thunk { + delay(100) + dispatch(Whatever) + } + // we need >1000ms here because 1000ms pass due the initializer and 100ms in the last thunk + delay(1200) + bloc.reduce { state + 1 } + } lifecycleRegistry.onStop() lifecycleRegistry.onDestroy() diff --git a/bloc-core/src/commonTest/kotlin/com/onegravity/bloc/internal/BlocLifecycleTests.kt b/bloc-core/src/commonTest/kotlin/com/onegravity/bloc/internal/BlocLifecycleTests.kt index 86608dd5..9d6ebf2c 100644 --- a/bloc-core/src/commonTest/kotlin/com/onegravity/bloc/internal/BlocLifecycleTests.kt +++ b/bloc-core/src/commonTest/kotlin/com/onegravity/bloc/internal/BlocLifecycleTests.kt @@ -1,15 +1,17 @@ package com.onegravity.bloc.internal import com.arkivanov.essenty.lifecycle.LifecycleRegistry -import com.onegravity.bloc.bloc -import com.onegravity.bloc.runTests -import com.onegravity.bloc.testState +import com.onegravity.bloc.* +import kotlinx.coroutines.delay import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotEquals class BlocLifecycleTests : BaseTestClass() { + /** + * Testing the Essenty lifecycle + */ @Test fun lifecycleTransitions() = runTests { LifecycleRegistry().legalTransition { onCreate() } @@ -91,18 +93,28 @@ class BlocLifecycleTests : BaseTestClass() { assertEquals(1, bloc.value) lifecycleRegistry.onStart() - testState(bloc, null, 2) + testState(bloc, null, 1) + testState(bloc, 1, 2) lifecycleRegistry.onStop() testState(bloc, 1, 2) + bloc.reduce { state + 5 } + delay(10) + assertEquals(2, bloc.value) + lifecycleRegistry.onStart() - testState(bloc, null, 3) + testState(bloc, null, 2) + testState(bloc, 1, 3) + + bloc.reduce { state + 5 } + delay(10) + assertEquals(8, bloc.value) lifecycleRegistry.onStop() lifecycleRegistry.onDestroy() - testState(bloc, 1, 3) + testState(bloc, 1, 8) } @Test @@ -124,10 +136,19 @@ class BlocLifecycleTests : BaseTestClass() { assertEquals(1, bloc.value) lifecycleRegistry.onStart() - testState(bloc, null, 3) + testState(bloc, null, 1) + testState(bloc, 1, 3) + + bloc.thunk { dispatch(1) } + delay(50) + assertEquals(5, bloc.value) lifecycleRegistry.onStop() - testState(bloc, 1, 3) + testState(bloc, 1, 5) + + bloc.thunk { dispatch(1) } + delay(50) + assertEquals(5, bloc.value) lifecycleRegistry.onStart() testState(bloc, null, 5) @@ -138,6 +159,7 @@ class BlocLifecycleTests : BaseTestClass() { testState(bloc, 1, 5) } + @Suppress("RemoveExplicitTypeArguments") @Test fun initializerLifecycleTest() = runTests { val lifecycleRegistry = LifecycleRegistry() @@ -161,11 +183,56 @@ class BlocLifecycleTests : BaseTestClass() { testState(bloc, 1, 8) lifecycleRegistry.onStart() - testState(bloc, null, 9) + testState(bloc, null, 8) + testState(bloc, 1, 9) + testState(bloc, 1, 10) lifecycleRegistry.onStop() + testState(bloc, 1, 10) + lifecycleRegistry.onDestroy() + testState(bloc, 1, 10) + } - testState(bloc, 1, 9) + @Suppress("RemoveExplicitTypeArguments") + @Test + fun initializerLifecycleTestEarlyStart() = runTests { + val lifecycleRegistry = LifecycleRegistry() + lifecycleRegistry.onCreate() + lifecycleRegistry.onStart() + + val context = BlocContextImpl(lifecycleRegistry) + val bloc = bloc(context, 1) { + onCreate { dispatch(7) } + reduce { state + action } + } + + delay(100) + assertEquals(8, bloc.value) + + lifecycleRegistry.onStop() + lifecycleRegistry.onDestroy() } + + @Suppress("RemoveExplicitTypeArguments") + @Test + fun initializerLifecycleTestEarlyStart2() = runTests { + val lifecycleRegistry = LifecycleRegistry() + lifecycleRegistry.onCreate() + lifecycleRegistry.onStart() + lifecycleRegistry.onStop() + + val context = BlocContextImpl(lifecycleRegistry) + val bloc = bloc(context, 1) { + onCreate { dispatch(7) } + reduce { state + action } + } + + delay(50) + bloc.send(1) + assertEquals(1, bloc.value) + + lifecycleRegistry.onDestroy() + } + } \ No newline at end of file diff --git a/bloc-core/src/commonTest/kotlin/com/onegravity/bloc/internal/BlocReducerExecutionTests.kt b/bloc-core/src/commonTest/kotlin/com/onegravity/bloc/internal/BlocReducerExecutionTests.kt index 15519b10..bdcf6a61 100644 --- a/bloc-core/src/commonTest/kotlin/com/onegravity/bloc/internal/BlocReducerExecutionTests.kt +++ b/bloc-core/src/commonTest/kotlin/com/onegravity/bloc/internal/BlocReducerExecutionTests.kt @@ -8,7 +8,6 @@ import kotlin.test.Test import kotlin.test.assertEquals class BlocReducerExecutionTests : BaseTestClass() { - @Test fun testReducerExecutionSimple() = runTests { val (bloc, lifecycleRegistry) = createBloc() @@ -29,6 +28,56 @@ class BlocReducerExecutionTests : BaseTestClass() { bloc.reduce { state - 4 } testState(bloc, null, 2) + lifecycleRegistry.onStop() + + testState(bloc, Increment, 2) + testState(bloc, Increment, 2) + testState(bloc, Increment, 2) + + // even if we restart the bloc, actions that were sent when it was stopped, are dropped + lifecycleRegistry.onStart() + delay(10) + assertEquals(2, bloc.value) + + lifecycleRegistry.onStop() + lifecycleRegistry.onDestroy() + } + + @Test + fun testReducerOrderOfExecution() = runTests { + val lifecycleRegistry = LifecycleRegistry() + val context = BlocContextImpl(lifecycleRegistry) + + var running = false + val bloc = bloc(context, 1) { + reduce { + running = true + // some longer running code + val count = (0..9999).fold(0) { acc, value -> acc + value } + repeat(10000) { count + 1 } + (state + 1).also { running = false } + } + reduce { + assertEquals(false, running, "Reducer 1 is still running!") + state - 1 + } + } + + assertEquals(1, bloc.value) + + lifecycleRegistry.onCreate() + lifecycleRegistry.onStart() + + testCollectState( + bloc, + listOf(1, 2, 1, 2, 1, 2, 1, 2, 1, 2, 1, 2, 1, 2, 1, 2, 1, 2, 1, 2, 1) + ) { + repeat(10) { + bloc.send(Increment) + bloc.send(Decrement) + } + } + lifecycleRegistry.onStop() lifecycleRegistry.onDestroy() } @@ -63,7 +112,7 @@ class BlocReducerExecutionTests : BaseTestClass() { delayReducerDec: Long = 0, delayReducerWhatever: Long = 0 ) = runTest { - val (bloc, lifecycleRegistry) = createBloc(delayReducerInc, delayReducerDec, delayReducerWhatever) + val (bloc, lifecycleRegistry) = createBloc() assertEquals(1, bloc.value) lifecycleRegistry.onCreate() @@ -84,7 +133,7 @@ class BlocReducerExecutionTests : BaseTestClass() { assertEquals(11, bloc.value) testCollectState( bloc, - listOf(11,10,9,8,7,6,5,4,3,2,1), + listOf(11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1), delayReducerDec.times(10).plus(100).coerceAtLeast(100) ) { repeat(10) { @@ -97,7 +146,7 @@ class BlocReducerExecutionTests : BaseTestClass() { assertEquals(1, bloc.value) testCollectState( bloc, - listOf(1,6,11,16,21,26,31,36,41,46,51), + listOf(1, 6, 11, 16, 21, 26, 31, 36, 41, 46, 51), delayReducerWhatever.times(10).plus(100).coerceAtLeast(100) ) { repeat(10) { @@ -109,8 +158,9 @@ class BlocReducerExecutionTests : BaseTestClass() { assertEquals(51, bloc.value) testCollectState( bloc, - listOf(51, 52,57,56, 57,62,61, 62,67,66), - (delayReducerInc + delayReducerDec + delayReducerWhatever + 100).times(3).coerceAtLeast(100) + listOf(51, 52, 57, 56, 57, 62, 61, 62, 67, 66), + (delayReducerInc + delayReducerDec + delayReducerWhatever + 100).times(3) + .coerceAtLeast(100) ) { repeat(3) { bloc.send(Increment) @@ -167,28 +217,20 @@ class BlocReducerExecutionTests : BaseTestClass() { lifecycleRegistry.onDestroy() } - private fun createBloc( - delayInc: Long = 0, - delayDec: Long = 0, - delayWhatever: Long = 0 - ) : Pair, LifecycleRegistry> { + private fun createBloc(): Pair, LifecycleRegistry> { val lifecycleRegistry = LifecycleRegistry() val context = BlocContextImpl(lifecycleRegistry) val bloc = bloc(context, 1) { reduce { - delay(delayInc) state + 1 } reduce { - delay(delayDec) state - 1 } reduce { - delay(delayWhatever) state + 5 } } return Pair(bloc, lifecycleRegistry) } - -} +} \ No newline at end of file diff --git a/bloc-core/src/commonTest/kotlin/com/onegravity/bloc/internal/BlocSideEffectTests.kt b/bloc-core/src/commonTest/kotlin/com/onegravity/bloc/internal/BlocSideEffectTests.kt index 3e58db13..b4eccd6d 100644 --- a/bloc-core/src/commonTest/kotlin/com/onegravity/bloc/internal/BlocSideEffectTests.kt +++ b/bloc-core/src/commonTest/kotlin/com/onegravity/bloc/internal/BlocSideEffectTests.kt @@ -41,4 +41,58 @@ class BlocSideEffectTests : BaseTestClass() { lifecycleRegistry.onDestroy() } + @Test + fun testMultipleSideEffects() = runTests { + val lifecycleRegistry = LifecycleRegistry() + val context = BlocContextImpl(lifecycleRegistry) + + val bloc = bloc(context, 1) { + reduce { state + 1 } + sideEffect { Open } + sideEffect { Open } + sideEffect { Close } + sideEffect { Something } + } + + lifecycleRegistry.onCreate() + lifecycleRegistry.onStart() + + testCollectSideEffects(bloc, listOf(Open, Open, Something, Open, Open, Something, Open, Open, Something)) { + repeat(3) { + bloc.send(Increment) + delay(10) + } + } + + lifecycleRegistry.onStop() + lifecycleRegistry.onDestroy() + } + + @Test + fun testThunksAndSideEffects() = runTests { + val lifecycleRegistry = LifecycleRegistry() + val context = BlocContextImpl(lifecycleRegistry) + + val bloc = bloc(context, 1) { + thunk { dispatch(Decrement) } + sideEffect { Open } + sideEffect { Open } + sideEffect { Close } + sideEffect { Something } + } + + lifecycleRegistry.onCreate() + lifecycleRegistry.onStart() + + testCollectSideEffects(bloc, listOf(Close, Something, Close, Something, Close, Something)) { + repeat(3) { + bloc.send(Increment) + delay(10) + } + } + + lifecycleRegistry.onStop() + lifecycleRegistry.onDestroy() + } + } \ No newline at end of file diff --git a/bloc-core/src/commonTest/kotlin/com/onegravity/bloc/internal/BlocThunkExecutionTests.kt b/bloc-core/src/commonTest/kotlin/com/onegravity/bloc/internal/BlocThunkExecutionTests.kt index f1f1053a..7ad7521d 100644 --- a/bloc-core/src/commonTest/kotlin/com/onegravity/bloc/internal/BlocThunkExecutionTests.kt +++ b/bloc-core/src/commonTest/kotlin/com/onegravity/bloc/internal/BlocThunkExecutionTests.kt @@ -32,13 +32,22 @@ class BlocThunkExecutionTests : BaseTestClass() { lifecycleRegistry.onStart() testState(bloc, null, 1) - assertEquals(8, count) + assertEquals(0, count) testState(bloc, Decrement, 1) - assertEquals(17, count) + assertEquals(9, count) testState(bloc, Whatever, 1) - assertEquals(24, count) + assertEquals(16, count) + + // even if we restart the bloc, actions that were sent when it was stopped, are dropped + lifecycleRegistry.onStop() + testState(bloc, Whatever, 1) + testState(bloc, Whatever, 1) + testState(bloc, Whatever, 1) + lifecycleRegistry.onStart() + assertEquals(1, bloc.value) + assertEquals(16, count) lifecycleRegistry.onStop() lifecycleRegistry.onDestroy() @@ -51,6 +60,8 @@ class BlocThunkExecutionTests : BaseTestClass() { var count = 0 val bloc = bloc(context, 1) { + reduce { state + 1 } + reduce { state } thunk { count++ dispatch(Increment) @@ -68,8 +79,6 @@ class BlocThunkExecutionTests : BaseTestClass() { count += 5 dispatch(action) } - reduce { state + 1 } - reduce { state } } assertEquals(1, bloc.value) @@ -78,8 +87,14 @@ class BlocThunkExecutionTests : BaseTestClass() { testState(bloc, Increment, 1) lifecycleRegistry.onStart() - testState(bloc, null, 9) + testState(bloc, null, 1) + assertEquals(0, count) + + testState(bloc, Increment, 9) assertEquals(61, count) + + lifecycleRegistry.onStop() + lifecycleRegistry.onDestroy() } @Test diff --git a/bloc-samples/build.gradle.kts b/bloc-samples/build.gradle.kts index 37e1098d..dc0b1578 100644 --- a/bloc-samples/build.gradle.kts +++ b/bloc-samples/build.gradle.kts @@ -65,8 +65,8 @@ kotlin { implementation(Ktor.client.logging) implementation(Ktor.client.json) implementation(Ktor.client.serialization) - implementation("io.ktor:ktor-client-content-negotiation:_") - implementation("io.ktor:ktor-serialization-kotlinx-json:_") + implementation(Ktor.client.contentNegotiation) + implementation(Ktor.plugins.serialization.kotlinx.json) // SQLDelight (https://cashapp.github.io/sqldelight/) implementation("com.squareup.sqldelight:runtime:_") diff --git a/docs/BLoC Architecture - BLoC Details.svg b/docs/BLoC Architecture - BLoC Details.svg deleted file mode 100644 index 88e91101..00000000 --- a/docs/BLoC Architecture - BLoC Details.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/docs/BLoC Architecture - BLoC Overview.svg b/docs/BLoC Architecture - BLoC Overview.svg deleted file mode 100644 index 49f8b008..00000000 --- a/docs/BLoC Architecture - BLoC Overview.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/gradle.properties b/gradle.properties index 8ab38c80..1e71d170 100644 --- a/gradle.properties +++ b/gradle.properties @@ -25,7 +25,7 @@ kotlin.internal.mpp.hierarchicalStructureByDefault=true # Maven POM_GROUP=com.1gravity -POM_VERSION_NAME=0.7.0 +POM_VERSION_NAME=0.8.0 # set PUBLISH_AS_SNAPSHOT to false to publish a release version PUBLISH_AS_SNAPSHOT=true diff --git a/todo.txt b/todo.txt index 279327b8..4ecef66e 100644 --- a/todo.txt +++ b/todo.txt @@ -1,15 +1,6 @@ -Tests: - -- TODO feature/redesign_initializers - synchronize initializers / onCreate execution with the lifecycle of the bloc - -> a bloc could not transition to Started while the initializer is still running - we would only need to synchronize the initializer code block itself and not wait for - dispatched actions to be processed because those actions won't be executed before the bloc is - started / transitions to Started - - - TODO Fix Subscriber extension functions aren't published... - TODO implement Subscriber tests +- TODO fix the theming - TODO implements Bloc tests with initializer, reducer, thunk, side effect - TODO implements BlocState tests diff --git a/versions.properties b/versions.properties index 8dff97ae..b73d392e 100644 --- a/versions.properties +++ b/versions.properties @@ -10,12 +10,12 @@ ## unused plugin.de.fayard.buildSrcLibs=0.40.2 -plugin.org.barfuin.gradle.taskinfo=1.4.0 +plugin.org.barfuin.gradle.taskinfo=2.0.0 # don't upgrade due to https://github.com/Kotlin/dokka/issues/2452 plugin.org.jetbrains.dokka=1.6.10 -version.androidx.activity=1.5.1 +version.androidx.activity=1.6.0 ## unused version.androidx.databinding=7.2.2 @@ -35,13 +35,13 @@ version.androidx.navigation=2.5.2 version.androidx.recyclerview=1.2.1 ## unused -version.com.1gravity..bloc-compose=0.7.0 +version.com.1gravity..bloc-compose=0.8.0 ## unused -version.com.1gravity..bloc-redux=0.7.0 +version.com.1gravity..bloc-redux=0.8.0 ## unused -version.com.1gravity..bloc-core=0.7.0 +version.com.1gravity..bloc-core=0.8.0 version.com.1gravity..redux-kotlin-threadsafe=0.5.10 @@ -71,13 +71,13 @@ version.com.badoo.reaktive..reaktive=1.2.2 version.androidx.multidex=2.0.1 - version.koin=3.2.1 + version.koin=3.2.2 version.kermit=1.1.3 version.ktor=2.1.1 -version.mockk=1.12.8 +version.mockk=1.13.1 version.kotlinx.coroutines=1.6.4 @@ -91,22 +91,22 @@ version.junit.junit=4.13.2 version.google.android.material=1.6.1 -version.app.cash.turbine=0.9.0 +version.app.cash.turbine=0.11.0 version.androidx.lifecycle=2.5.1 -version.androidx.fragment=1.5.2 +version.androidx.fragment=1.5.3 version.androidx.core=1.9.0 version.androidx.constraintlayout=2.1.4 -version.androidx.appcompat=1.5.0 +version.androidx.appcompat=1.5.1 ## unused version.org.jacoco..org.jacoco.ant=0.8.7 -version.org.jetbrains.compose..compose-gradle-plugin=1.2.0-alpha01-dev786 +version.org.jetbrains.compose..compose-gradle-plugin=1.2.0-beta02-dev795 ## unused version.org.jetbrains.dokka..jekyll-template-processing-plugin=1.4.32 @@ -128,4 +128,6 @@ version.org.jetbrains.compose.compiler..compiler=1.3.0-alpha01 plugin.android=7.3.0 +version.org.jetbrains.kotlinx..atomicfu=0.18.3 + version.sqldelight=1.5.3 diff --git a/website/docs/architecture/bloc/bloc.md b/website/docs/architecture/bloc/bloc.md index b40b2e98..fae4eadc 100644 --- a/website/docs/architecture/bloc/bloc.md +++ b/website/docs/architecture/bloc/bloc.md @@ -72,19 +72,13 @@ Processing an `Action` usually means invoking a `Reducer`: > A reducer is a function that receives the current state and an action object, decides how to update the state if necessary, and returns the new state: (state, action) => newState (https://redux.js.org/tutorials/fundamentals/part-2-concepts-data-flow) -Above definition is the official Redux reducer definition and captures its essence, although reducers in the context of `Kotlin Bloc` are a bit more complex: +Above definition is the official Redux reducer definition and captures its essence. Reducers in the context of `Kotlin Bloc` are very similar: ```kotlin -suspend (State, Action) -> Proposal +(State, Action) -> Proposal ``` -Compared to a Redux reducer, this one: -1. is suspending -2. returns a `Proposal` instead of `State` - -More details can be found in [Reducer](./bloc/reducer). - -Reducers are asynchronous in nature when they are defined using one of the [BlocBuilders](./bloc/bloc_builder). They can be synchronous when defined using [BlocOwner](./blocowner/bloc_owner) extensions. +Compared to a Redux reducer, this one returns a `Proposal` instead of `State`. More details can be found in [Reducer](./bloc/reducer). ## Side Effect @@ -103,18 +97,17 @@ The difference between a "regular" reducer and one with side effects is simply t ```kotlin // no side effects -suspend (State, Action) -> Proposal +(State, Action) -> Proposal // with side effects -suspend (State, Action) -> Effect +(State, Action) -> Effect ``` When the framework detects an `Effect` it will emit the side effects to a dedicated `Stream` that can be observed separately from the regular `State` stream. There's a DSL that make it easy to "build" reducers with side effects (see [Reducer](./bloc/reducer)). ## Thunk -While reducers are normally asynchronous in nature, their intended purpose is to update `State` right away to make sure the user interface is responsive to user input and updates "without" perceptible delay. -Longer running operations should be executed using a `Thunk`: +Reducers are synchronous in nature and their intended purpose is to update `State` right away to make sure the user interface is responsive to user input and updates "without" perceptible delay. Longer running operations should be executed using a `Thunk`: >The word "thunk" is a programming term that means "a piece of code that does some delayed work". Rather than execute some logic now, we can write a function body or code that can be used to perform the work later. https://redux.js.org/usage/writing-logic-thunks diff --git a/website/docs/architecture/bloc/initializer.md b/website/docs/architecture/bloc/initializer.md index 61e265a8..ffd2eb75 100644 --- a/website/docs/architecture/bloc/initializer.md +++ b/website/docs/architecture/bloc/initializer.md @@ -47,12 +47,10 @@ onCreate { } ``` -The order of declaration is irrelevant, the initializer will always be called first (see [Lifecycle](lifecycle)). It could however be that the initializer is still running when the bloc starts processing actions (thunks and reducers). This behavior might change in a future version. +The order of declaration is irrelevant, the initializer will always be called first. The bloc also waits for the initializer to finish before processing thunks or reducers. Actions dispatched before the initializer finishes (between `onCreate()` and `onStart()`) are sent to a queue and are processed once the bloc transitions to the `Started` state (see [Lifecycle](lifecycle)). +That's true for actions dispatched by the initializer itself and also for actions dispatched directly to the bloc (MVVM+ style). +The only exception to that rule is if the initializer launches asynchronous code e.g. via [launch](coroutine_launcher) and would dispatch actions from there. :::tip If more than one initializer is defined, the first one (according to their order of declaration) is used, all others are ignored. ::: - -:::tip -There are extension functions to launch `Coroutines` from an initializer (see [Coroutine Launcher](coroutine_launcher)). -::: diff --git a/website/docs/architecture/bloc/launcher.md b/website/docs/architecture/bloc/launcher.md index d56bb41f..d108d058 100644 --- a/website/docs/architecture/bloc/launcher.md +++ b/website/docs/architecture/bloc/launcher.md @@ -65,5 +65,5 @@ fun onSelected(post: Post) = thunk { } ``` :::tip -The `CoroutineScope` could be exposed through the context (InitializerContext, ThunkContext, ReducerContext) in order to facilitate the launch of new coroutines. However I decided to encapsulate that scope to prevent "unauthorized interventions" (like cancellations). This design decision could be temporary. +The `CoroutineScope` could be exposed through the context (InitializerContext, ThunkContext, ReducerContext) in order to facilitate the launch of new coroutines. However I decided to encapsulate that scope to prevent "unauthorized interventions" (like cancellations). This design decision could be changed in the future. ::: \ No newline at end of file diff --git a/website/docs/architecture/bloc/lifecycle.md b/website/docs/architecture/bloc/lifecycle.md index f117ba85..c3b59e0d 100644 --- a/website/docs/architecture/bloc/lifecycle.md +++ b/website/docs/architecture/bloc/lifecycle.md @@ -5,12 +5,13 @@ sidebar_label: Lifecycle hide_title: true --- -## Two/Three Lifecycles +## Four Lifecycles -A bloc has two or three relevant lifecycles: -- The `View` lifecycle which is a platform-specific lifecycle. -- The [Essenty](https://github.com/arkivanov/Essenty) lifecycle which thanslates the `View` lifecycle to a platform-agnostic lifecycle. -- Depending on the `View` implementation, there can be a "component" lifecycle which sits in between the other two lifecycles. On Android that would typically be a `ViewModel` lifecycle. On iOS there's a `BlocHolder` or `BlocComponent` lifecycle. This and how it ties to the other two lifecycles will be discussed in more detail in [Extensions](../../extensions/overview). +A bloc has four relevant lifecycles: +1. The `View` lifecycle which is a platform-specific lifecycle. +2. The [Essenty](https://github.com/arkivanov/Essenty) lifecycle which translates the `View` lifecycle to a platform-agnostic lifecycle. +3. There's typically a platform specific lifecycle between the `View` and the [Essenty](https://github.com/arkivanov/Essenty) lifecycle. On Android that would normally be a `ViewModel` lifecycle. On iOS there's a `BlocHolder` or `BlocComponent` lifecycle. This and how it ties to the other two lifecycles will be discussed in more detail in [Extensions](../../extensions/overview). +4. The bloc has a lifecycle of its own. It's "driven" by the [Essenty](https://github.com/arkivanov/Essenty) lifecycle but adds states to manage its internal workings (see [Bloc Lifecycle](lifecycle.md#bloc-lifecycle)). From a `Bloc's` perspective, the `Essenty` lifecycle is the most relevant one. @@ -20,11 +21,14 @@ From a `Bloc's` perspective, the `Essenty` lifecycle is the most relevant one. ### onCreate() -`onCreate()` is called when the component controlling the lifecycle is created. If there's an [Initializer](initializer), the bloc will execute it in its own `CoroutineScope`. +`onCreate()` is called when the component controlling the lifecycle is created. If there's an [Initializer](initializer), the bloc will start executing it in its own `CoroutineScope`. ### onStart() `onStart()` is called when the component controlling the lifecycle is started. That's typically when the UI is being displayed / rendered (platform specific meaning). This is the moment the bloc starts processing actions for thunks and reducers. The workers that process the two queues / channels are started within their own `CoroutineScope`. +:::tip +`onStart()` doesn't move the bloc to its `Started` state if the initializer hasn't finished yet (see [Bloc Lifecycle](lifecycle.md#bloc-lifecycle)) +::: ### onResume() / onPause() @@ -34,24 +38,34 @@ These two events are ignored by the bloc. `onStop()` is called when the component controlling the lifecycle is stopped. That's typically when the UI stops showing (platform specific meaning). When this happens, the bloc stops processing actions for thunks and reducers. The `CoroutineScopes` created for the workers that process the two queues / channels are cancelled (and thus will all started coroutines). -Note that the bloc still accepts actions, even though they aren't processed when the bloc is stopped. Processing of actions will resume when `onStart()` is called again (although all coroutines were cancelled so resume only applies to the processing of the two queues). +:::tip +After `onStop()` all actions sent to the bloc will be ignored. If the bloc is started again (`onStart()`), new actions will be processed but the ones that were sent in `Stopped` state are gone. +::: ### onDestroy() When the component controlling the lifecycle is destroyed, the Bloc will cancel the -[Initializer](initializer) `CoroutineScope`. If the initializer is still running, it would be stopped (all couroutines are cancelled). +[Initializer](initializer) `CoroutineScope`. If the initializer is still running, it would be stopped (all coroutines are cancelled). Once a bloc is destroyed, it's can't be used any more (`onCreate` will be ignored). +## Bloc Lifecycle +The Bloc lifecycle is driven by the [Essenty](https://github.com/arkivanov/Essenty) lifecycle on one hand and by the logic to synchronize initializer and reducer/thunk execution on the other hand. As explained [here](./initializer.md) initializers need to finish execution before the bloc transitions to the `Started` state (which will start the processing of thunks and reducers). + +The Bloc lifecycle is driven by the following state machine: + +![Bloc Architecture - Details](../../../static/img/Bloc%20Architecture%20-%20Internal%20Lifecycle.svg) + +For `Kotlin Bloc` users this isn't really relevant. Important to remember is only the fact that the order of execution is guaranteed. An initializer will always run and terminate before thunks and reducers are executed, regardless of the [Essenty](https://github.com/arkivanov/Essenty) lifecycle. Actions (and thunks/reducers in MVVM+ style) dispatched to the bloc after [Essenty's](https://github.com/arkivanov/Essenty) lifecycle `onStart()` will be sent to a queue and processed once the initializer is done (in the same order they were sent). ## CoroutineScopes A bloc has three `CoroutineScopes` which are tied to the lifecycle of the bloc as follows: -| Lifecycle Event | CoroutineScope | Operation | -| --------------- | ----------------- | ---------------------------------------------- | -| onCreate() | InitializerScope | execute Initializer | -| onStart() | ThunkScope | create scope -> start processing thunk queue | -| | ReduceScope | create scope -> start processing reducer queue | -| onStop() | ThunkScope | cancel scope -> stop thunk coroutines | -| | ReduceScope | cancel scope -> stop reduce coroutines | -| onDestroy() | InitializerScope | cancel scope -> stop initializer coroutines | +| Lifecycle Event | CoroutineScope | Operation | +| --------------- | ----------------- | ------------------------------------------------------------------------ | +| onCreate() | InitializerScope | create scope -> start initializer | +| onStart() | ThunkScope | create scope -> start processing thunk queue (if initializer is done) | +| | ReduceScope | create scope -> start processing reducer queue (if initializer is done) | +| onStop() | ThunkScope | cancel scope -> stop thunk coroutines | +| | ReduceScope | cancel scope -> stop reduce coroutines | +| onDestroy() | InitializerScope | cancel scope -> stop initializer coroutines | diff --git a/website/docs/architecture/bloc/reducer.md b/website/docs/architecture/bloc/reducer.md index 91b848ea..f71e8a7b 100644 --- a/website/docs/architecture/bloc/reducer.md +++ b/website/docs/architecture/bloc/reducer.md @@ -13,11 +13,9 @@ To reiterate: While the official Redux reducer definition captures the essence, reducers in the context of `Kotlin Bloc` are slightly different: ```kotlin -suspend (State, Action) -> Proposal +(State, Action) -> Proposal ``` -Compared to a Redux reducer, "our" reducer is: -1. suspending -2. returns a `Proposal` instead of `State` +Compared to a Redux reducer, "our" reducer returns a `Proposal` instead of `State`. ### Context @@ -53,14 +51,14 @@ Default: reducer returns `Proposal`: ```kotlin // reducer without side effects -suspend (State, Action) -> Proposal +(State, Action) -> Proposal ``` Exception: reducer returns `Effect`: ```kotlin // reducer with side effects -suspend (State, Action) -> Effect +(State, Action) -> Effect public data class Effect( val proposal: Proposal?, @@ -151,7 +149,7 @@ reduce { } ``` -This behavior also makes sense since state must be reduced only once. Whichever matching reducer is declared first is the one being called. While the order of declaration is relevant for reducers (if they match the same action), it's not for thunks and reducers, thunks will always be executed first. +This behavior also makes sense since state must be reduced only once. Whichever matching reducer is declared first is the one being called. The order of declaration is relevant for reducers (if they match the same action). If there are matching thunks and reducers, the order of declaration is irrelevant, thunks will always be executed first. What about side effects? @@ -186,19 +184,16 @@ object Decrement : Action() private val bloc by getOrCreate { bloc(it, 1) { reduce { - delay(10000) + // heavy computation state + 1 } reduce { - delay(5000) state - 1 } } } ``` Even if `Increment` and `Decrement` are sent in quick succession, `Increment` will always be processed first and the reducer will finish before the `Decrement` action is processed. -1. `Increment` reducer starts, waits 10 seconds and sets the counter to 2 -2. `Decrement` reducer starts, waits 5 seconds and sets the counter to 1 :::tip This behavior is true for reducers triggered by an action (Redux style) or triggered by a function (MVVM+ style) (see [BlocOwner](../blocowner/bloc_owner.md#blocowner)). As a matter of fact, both types of reducers are sent to the same queue to be processed. @@ -256,7 +251,8 @@ enum class Action { ## A Matter of Taste -Reducers can be catch-all reducers or they can be single-action reducers (we can also use a combination of the two). Catch-all reducers are the traditional/Redux style of writing reducers. +Reducers can be catch-all reducers or they can be single-action reducers (we can also use a combination of the two). +Catch-all reducers are the traditional/Redux style of writing reducers. Catch-all reducers make sense if you want the reducer logic in one place like in this (calculator) example: @@ -319,4 +315,4 @@ reduce { It's a "matter of taste", which style you prefer. While the traditional/Redux style is to have reducers dealing with different actions, [this article](https://dev.to/feresr/a-case-against-the-mvi-architecture-pattern-1add) advocates for splitting reducers into smaller chunks. -[Orbit](https://orbit-mvi.org/) is one of the frameworks championing the MVVM+ style (that's what they call it in [this article](https://appmattus.medium.com/top-android-mvi-libraries-in-2021-de1afe890f27)) and it served as inspiration for some of the `Kotlin Bloc` design although they take the idea one step further (see [BlowOwner](../blocowner/bloc_owner)). +[Orbit](https://orbit-mvi.org/) is one of the frameworks championing the MVVM+ style (that's what they call it in [this article](https://appmattus.medium.com/top-android-mvi-libraries-in-2021-de1afe890f27)) and it served as inspiration for some of the `Kotlin Bloc` design, although we take the idea one step further (see [BlowOwner](../blocowner/bloc_owner)). diff --git a/website/docs/architecture/blocowner/bloc_owner.md b/website/docs/architecture/blocowner/bloc_owner.md index b091fc7b..dd0cd9c3 100644 --- a/website/docs/architecture/blocowner/bloc_owner.md +++ b/website/docs/architecture/blocowner/bloc_owner.md @@ -79,9 +79,10 @@ class PostListViewModel(context: ActivityBlocContext) : ViewModel(), override val bloc = bloc( blocContext(context), blocState(PostsState()) - ) - - init { + ) { + // note: an initializer needs to be defined in the bloc because it can't transition + // to Started state if it doesn't know if and when an initializer is submitted + // (no MVVM+ style initializers!) onCreate { if (state.isEmpty()) { loading() diff --git a/website/docs/getting_started/examples.md b/website/docs/getting_started/examples.md index 97c778a6..186e0f6a 100644 --- a/website/docs/getting_started/examples.md +++ b/website/docs/getting_started/examples.md @@ -225,15 +225,6 @@ class PostViewModel : ViewModel(), BlocOwner { } } - init { - // initializer, MVVM+ style - onCreate { - if (state.posts.isEmpty()) { - load() - } - } - } - // thunks for asynchronous operations, MVVM+ style private fun load() = thunk { dispatch(Loading) diff --git a/website/docs/getting_started/setup.md b/website/docs/getting_started/setup.md index 13aa25e6..879c4834 100644 --- a/website/docs/getting_started/setup.md +++ b/website/docs/getting_started/setup.md @@ -12,12 +12,12 @@ hide_title: true ```kotlin dependencies { // the core library - implementation("com.1gravity:bloc-core:0.7.0") + implementation("com.1gravity:bloc-core:0.8.0") // add to use the framework together with Redux - implementation("com.1gravity:bloc-redux:0.7.0") + implementation("com.1gravity:bloc-redux:0.8.0") // useful extensions for Android and Jetpack/JetBrains Compose - implementation("com.1gravity:bloc-compose:0.7.0") + implementation("com.1gravity:bloc-compose:0.8.0") } ``` diff --git a/website/static/img/Bloc Architecture - Internal Lifecycle.svg b/website/static/img/Bloc Architecture - Internal Lifecycle.svg index cbc565bc..7710572b 100644 --- a/website/static/img/Bloc Architecture - Internal Lifecycle.svg +++ b/website/static/img/Bloc Architecture - Internal Lifecycle.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file