diff --git a/formula-android-tests/src/test/java/com/instacart/formula/FragmentAndroidEventTest.kt b/formula-android-tests/src/test/java/com/instacart/formula/FragmentAndroidEventTest.kt index b21688879..517aaa05d 100644 --- a/formula-android-tests/src/test/java/com/instacart/formula/FragmentAndroidEventTest.kt +++ b/formula-android-tests/src/test/java/com/instacart/formula/FragmentAndroidEventTest.kt @@ -5,6 +5,7 @@ import androidx.test.ext.junit.rules.ActivityScenarioRule import androidx.test.ext.junit.runners.AndroidJUnit4 import com.google.common.truth.Truth.assertThat import com.instacart.formula.android.Feature +import com.instacart.formula.android.FeatureFactory import com.instacart.formula.android.ViewFactory import com.instacart.formula.android.events.ActivityResult import com.instacart.formula.test.TestFragmentActivity @@ -29,18 +30,21 @@ class FragmentAndroidEventTest { initialContract = TestLifecycleKey() }, contracts = { - - bind { _, _ -> - Feature( - state = activityResults().flatMap { - activityResults.add(it) - Observable.empty() - }, - viewFactory = ViewFactory.fromLayout(R.layout.test_empty_layout) { - featureView { } - } - ) + val featureFactory = object : FeatureFactory { + override fun initialize(dependencies: Unit, key: TestLifecycleKey): Feature { + return Feature( + state = activityResults().flatMap { + activityResults.add(it) + Observable.empty() + }, + viewFactory = ViewFactory.fromLayout(R.layout.test_empty_layout) { + featureView { } + } + ) + } } + + bind(featureFactory) } ) } @@ -48,7 +52,8 @@ class FragmentAndroidEventTest { }, cleanUp = { activityResults.clear() - }) + } + ) private val activityRule = ActivityScenarioRule(TestFragmentActivity::class.java) diff --git a/formula-android/src/main/java/com/instacart/formula/android/FragmentFlowStore.kt b/formula-android/src/main/java/com/instacart/formula/android/FragmentFlowStore.kt index 20bb191a5..7302e0437 100644 --- a/formula-android/src/main/java/com/instacart/formula/android/FragmentFlowStore.kt +++ b/formula-android/src/main/java/com/instacart/formula/android/FragmentFlowStore.kt @@ -1,25 +1,18 @@ package com.instacart.formula.android -import com.instacart.formula.Evaluation -import com.instacart.formula.Formula import com.instacart.formula.RuntimeConfig -import com.instacart.formula.Snapshot -import com.instacart.formula.android.internal.Binding import com.instacart.formula.android.events.FragmentLifecycleEvent -import com.instacart.formula.android.internal.CompositeBinding -import com.instacart.formula.android.internal.FeatureObservableAction +import com.instacart.formula.android.internal.FragmentFlowStoreFormula import com.instacart.formula.android.utils.MainThreadDispatcher -import com.instacart.formula.rxjava3.RxAction import com.instacart.formula.rxjava3.toObservable -import com.jakewharton.rxrelay3.PublishRelay import io.reactivex.rxjava3.core.Observable /** * A FragmentFlowStore is responsible for managing the state of multiple [FragmentKey] instances. */ class FragmentFlowStore @PublishedApi internal constructor( - private val root: CompositeBinding<*>, -) : Formula() { + private val formula: FragmentFlowStoreFormula<*>, +) { companion object { inline fun init( crossinline init: FragmentStoreBuilder.() -> Unit @@ -32,119 +25,23 @@ class FragmentFlowStore @PublishedApi internal constructor( crossinline contracts: FragmentStoreBuilder.() -> Unit ): FragmentFlowStore { val bindings = FragmentStoreBuilder.build(contracts) - val root = CompositeBinding(rootComponent, bindings.bindings) - return FragmentFlowStore(root) + val formula = FragmentFlowStoreFormula(rootComponent, bindings) + return FragmentFlowStore(formula) } } - - private val lifecycleEvents = PublishRelay.create() - private val visibleContractEvents = PublishRelay.create() - private val hiddenContractEvents = PublishRelay.create() - - private val lifecycleEventStream = RxAction.fromObservable { lifecycleEvents } - private val visibleContractEventStream = RxAction.fromObservable { visibleContractEvents } - private val hiddenContractEventStream = RxAction.fromObservable { hiddenContractEvents } - internal fun onLifecycleEffect(event: FragmentLifecycleEvent) { - lifecycleEvents.accept(event) + formula.onLifecycleEffect(event) } internal fun onVisibilityChanged(contract: FragmentId, visible: Boolean) { - if (visible) { - visibleContractEvents.accept(contract) - } else { - hiddenContractEvents.accept(contract) - } - } - - override fun initialState(input: FragmentEnvironment): FragmentFlowState = FragmentFlowState() - - override fun Snapshot.evaluate(): Evaluation { - val rootInput = Binding.Input( - environment = input, - component = Unit, - activeFragments = state.activeIds, - onInitializeFeature = context.onEvent { event -> - val features = state.features.plus(event.id to event) - transition(state.copy(features = features)) - } - ) - root.bind(context, rootInput) - - return Evaluation( - output = state, - actions = context.actions { - lifecycleEventStream.onEvent { event -> - val fragmentId = event.fragmentId - when (event) { - is FragmentLifecycleEvent.Removed -> { - val updated = state.copy( - activeIds = state.activeIds.minus(fragmentId), - states = state.states.minus(fragmentId), - features = state.features.minus(fragmentId) - ) - transition(updated) - } - is FragmentLifecycleEvent.Added -> { - if (!state.activeIds.contains(fragmentId)) { - if (root.binds(fragmentId.key)) { - val updated = state.copy(activeIds = state.activeIds.plus(fragmentId)) - transition(updated) - } else { - val updated = state.copy( - activeIds = state.activeIds.plus(fragmentId), - features = state.features.plus(fragmentId to FeatureEvent.MissingBinding(fragmentId)) - ) - transition(updated) - } - } else { - none() - } - } - } - } - - visibleContractEventStream.onEvent { - if (state.visibleIds.contains(it)) { - // TODO: should we log this duplicate visibility event? - none() - } else { - transition(state.copy(visibleIds = state.visibleIds.plus(it))) - } - } - - hiddenContractEventStream.onEvent { - transition(state.copy(visibleIds = state.visibleIds.minus(it))) - } - - state.features.entries.forEach { entry -> - val fragmentId = entry.key - val feature = (entry.value as? FeatureEvent.Init)?.feature - if (feature != null) { - val action = FeatureObservableAction( - fragmentEnvironment = input, - fragmentId = fragmentId, - feature = feature, - ) - action.onEvent { - if (state.activeIds.contains(fragmentId)) { - val keyState = FragmentState(fragmentId.key, it) - transition(state.copy(states = state.states.plus(fragmentId to keyState))) - } else { - none() - } - } - } - } - } - ) + formula.onVisibilityChanged(contract, visible) } internal fun state(environment: FragmentEnvironment): Observable { val config = RuntimeConfig( defaultDispatcher = MainThreadDispatcher(), ) - return toObservable(environment, config) + return formula.toObservable(environment, config) } } diff --git a/formula-android/src/main/java/com/instacart/formula/android/FragmentStoreBuilder.kt b/formula-android/src/main/java/com/instacart/formula/android/FragmentStoreBuilder.kt index 6aa3f30fb..f3e906233 100644 --- a/formula-android/src/main/java/com/instacart/formula/android/FragmentStoreBuilder.kt +++ b/formula-android/src/main/java/com/instacart/formula/android/FragmentStoreBuilder.kt @@ -1,9 +1,7 @@ package com.instacart.formula.android -import com.instacart.formula.android.internal.Binding -import com.instacart.formula.android.internal.Bindings -import com.instacart.formula.android.internal.FunctionUtils import com.instacart.formula.android.internal.FeatureBinding +import com.instacart.formula.android.internal.MappedFeatureFactory import java.lang.IllegalStateException import kotlin.reflect.KClass @@ -13,33 +11,16 @@ import kotlin.reflect.KClass */ class FragmentStoreBuilder { companion object { - @PublishedApi internal inline fun build( init: FragmentStoreBuilder.() -> Unit - ): Bindings { + ): List> { return FragmentStoreBuilder().apply(init).build() } } private val types = mutableSetOf>() - private val bindings: MutableList> = mutableListOf() - - /** - * Binds a [feature factory][FeatureFactory] for a specific [key][type]. - * - * @param type The class which describes the [key][Key]. - * @param featureFactory Feature factory that provides state observable and view rendering logic. - * @param toDependencies Maps [Component] to feature factory [dependencies][Dependencies]. - */ - fun bind( - type : KClass, - featureFactory: FeatureFactory, - toDependencies: (Component) -> Dependencies - ) = apply { - val binding = FeatureBinding(type.java, featureFactory, toDependencies) - bind(binding as Binding) - } + private val bindings: MutableList> = mutableListOf() /** * Binds a [feature factory][FeatureFactory] for a specific [key][type]. @@ -49,13 +30,14 @@ class FragmentStoreBuilder { */ fun bind( type : KClass, - featureFactory: FeatureFactory + featureFactory: FeatureFactory, ) = apply { - bind(type, featureFactory, FunctionUtils.identity()) + val binding = FeatureBinding(type.java, featureFactory) + bind(type.java, binding) } /** - * A convenience inline function that binds a feature factory for a specific [key][Key]. + * Binds a feature factory for a [Key]. * * @param featureFactory Feature factory that provides state observable and view rendering logic. */ @@ -65,22 +47,6 @@ class FragmentStoreBuilder { bind(Key::class, featureFactory) } - /** - * A convenience inline function that binds a feature factory for a specific [key][Key]. - * - * @param featureFactory Feature factory that provides state observable and view rendering logic. - */ - inline fun bind( - crossinline initFeature: (Component, Key) -> Feature, - ) = apply { - val factory = object : FeatureFactory { - override fun initialize(dependencies: Component, key: Key): Feature { - return initFeature(dependencies, key) - } - } - bind(Key::class, factory) - } - /** * A convenience inline function that binds a feature factory for a specific [key][Key]. * @@ -91,18 +57,19 @@ class FragmentStoreBuilder { featureFactory: FeatureFactory, noinline toDependencies: (Component) -> Dependencies ) = apply { - bind(Key::class, featureFactory, toDependencies) + val mapped = MappedFeatureFactory( + delegate = featureFactory, + toDependencies = toDependencies, + ) + bind(Key::class, mapped) } @PublishedApi - internal fun build(): Bindings { - return Bindings( - bindings = bindings - ) + internal fun build(): List> { + return bindings } - private fun bind(binding: Binding) = apply { - val type = binding.type() + private fun bind(type: Class<*>, binding: FeatureBinding) = apply { if (types.contains(type)) { throw IllegalStateException("Binding for $type already exists") } diff --git a/formula-android/src/main/java/com/instacart/formula/android/internal/Binding.kt b/formula-android/src/main/java/com/instacart/formula/android/internal/Binding.kt deleted file mode 100644 index 8d630ff8e..000000000 --- a/formula-android/src/main/java/com/instacart/formula/android/internal/Binding.kt +++ /dev/null @@ -1,31 +0,0 @@ -package com.instacart.formula.android.internal - -import com.instacart.formula.FormulaContext -import com.instacart.formula.android.FeatureEvent -import com.instacart.formula.android.FragmentEnvironment -import com.instacart.formula.android.FragmentId - -/** - * Defines how specific keys bind to the state management associated - */ -@PublishedApi -internal abstract class Binding { - data class Input( - val environment: FragmentEnvironment, - val component: Component, - val activeFragments: List, - val onInitializeFeature: (FeatureEvent) -> Unit, - ) - - internal abstract fun type(): Class<*> - - /** - * Returns true if this binding handles this [key] - */ - internal abstract fun binds(key: Any): Boolean - - /** - * Listens for active key changes and triggers [Input.onStateChanged] events. - */ - internal abstract fun bind(context: FormulaContext<*, *>, input: Input) -} diff --git a/formula-android/src/main/java/com/instacart/formula/android/internal/Bindings.kt b/formula-android/src/main/java/com/instacart/formula/android/internal/Bindings.kt deleted file mode 100644 index ebb388ed6..000000000 --- a/formula-android/src/main/java/com/instacart/formula/android/internal/Bindings.kt +++ /dev/null @@ -1,6 +0,0 @@ -package com.instacart.formula.android.internal - -@PublishedApi -internal class Bindings( - val bindings: List> -) diff --git a/formula-android/src/main/java/com/instacart/formula/android/internal/CompositeBinding.kt b/formula-android/src/main/java/com/instacart/formula/android/internal/CompositeBinding.kt deleted file mode 100644 index 8d681d45c..000000000 --- a/formula-android/src/main/java/com/instacart/formula/android/internal/CompositeBinding.kt +++ /dev/null @@ -1,52 +0,0 @@ -package com.instacart.formula.android.internal - -import com.instacart.formula.Evaluation -import com.instacart.formula.FormulaContext -import com.instacart.formula.Snapshot -import com.instacart.formula.StatelessFormula -import com.instacart.formula.android.internal.Binding.Input - -/** - * Defines how a group of keys should be bound to their integrations. - * - * @param ParentComponent A component associated with the parent. Often this will map to the parent dagger component. - * @param ScopedComponent A component that is initialized when user enters this flow and is shared between - * all the screens within the flow. Component will be destroyed when user exists the flow. - */ -@PublishedApi -internal class CompositeBinding( - private val component: ScopedComponent, - private val bindings: List> -) { - - private val formula = object : StatelessFormula, Unit>() { - override fun key(input: Input): Any = this - - override fun Snapshot, Unit>.evaluate(): Evaluation { - val childInput = Input( - environment = input.environment, - component = component, - activeFragments = input.activeFragments, - onInitializeFeature = input.onInitializeFeature, - ) - bindings.forEachIndices { - it.bind(context, childInput) - } - return Evaluation( - output = Unit, - ) - } - } - - - fun binds(key: Any): Boolean { - bindings.forEachIndices { - if (it.binds(key)) return true - } - return false - } - - fun bind(context: FormulaContext<*, *>, input: Input) { - context.child(formula, input) - } -} diff --git a/formula-android/src/main/java/com/instacart/formula/android/internal/FeatureBinding.kt b/formula-android/src/main/java/com/instacart/formula/android/internal/FeatureBinding.kt index 4aba834d1..fd4e7732e 100644 --- a/formula-android/src/main/java/com/instacart/formula/android/internal/FeatureBinding.kt +++ b/formula-android/src/main/java/com/instacart/formula/android/internal/FeatureBinding.kt @@ -1,67 +1,12 @@ package com.instacart.formula.android.internal -import com.instacart.formula.Action -import com.instacart.formula.Evaluation -import com.instacart.formula.Formula -import com.instacart.formula.FormulaContext -import com.instacart.formula.Snapshot import com.instacart.formula.android.FeatureFactory import com.instacart.formula.android.FragmentKey -import com.instacart.formula.android.FeatureEvent /** - * Defines how a specific key should be bound to its [FeatureFactory], + * Defines how a specific key should be bound to its [FeatureFactory] */ -internal class FeatureBinding( - private val type: Class, - private val feature: FeatureFactory, - private val toDependencies: (Component) -> Dependencies -) : Binding() { - - private val formula = object : Formula, Unit, Unit>() { - override fun key(input: Input): Any = type - - override fun initialState(input: Input) = Unit - - override fun Snapshot, Unit>.evaluate(): Evaluation { - return Evaluation( - output = state, - actions = context.actions { - input.activeFragments.forEachIndices { fragmentId -> - val key = fragmentId.key - if (binds(key)) { - Action.onData(fragmentId).onEvent { - transition { - try { - val dependencies = toDependencies(input.component) - val feature = input.environment.fragmentDelegate.initializeFeature( - fragmentId = fragmentId, - factory = feature, - dependencies = dependencies, - key = key as Key, - ) - input.onInitializeFeature(FeatureEvent.Init(fragmentId, feature)) - } catch (e: Exception) { - input.onInitializeFeature(FeatureEvent.Failure(fragmentId, e)) - } - } - } - } - } - } - ) - } - } - - override fun type(): Class<*> { - return type - } - - override fun binds(key: Any): Boolean { - return type.isInstance(key) - } - - override fun bind(context: FormulaContext<*, *>, input: Input) { - context.child(formula, input) - } -} +class FeatureBinding( + val type: Class, + val feature: FeatureFactory, +) diff --git a/formula-android/src/main/java/com/instacart/formula/android/internal/FragmentFlowStoreFormula.kt b/formula-android/src/main/java/com/instacart/formula/android/internal/FragmentFlowStoreFormula.kt new file mode 100644 index 000000000..7b2bfb3c6 --- /dev/null +++ b/formula-android/src/main/java/com/instacart/formula/android/internal/FragmentFlowStoreFormula.kt @@ -0,0 +1,135 @@ +package com.instacart.formula.android.internal + +import com.instacart.formula.Evaluation +import com.instacart.formula.Formula +import com.instacart.formula.Snapshot +import com.instacart.formula.android.FeatureEvent +import com.instacart.formula.android.FeatureFactory +import com.instacart.formula.android.FragmentEnvironment +import com.instacart.formula.android.FragmentFlowState +import com.instacart.formula.android.FragmentId +import com.instacart.formula.android.FragmentKey +import com.instacart.formula.android.FragmentState +import com.instacart.formula.android.events.FragmentLifecycleEvent +import com.instacart.formula.rxjava3.RxAction +import com.jakewharton.rxrelay3.PublishRelay + +@PublishedApi +internal class FragmentFlowStoreFormula( + private val component: Component, + private val bindings: List>, +) : Formula(){ + private val lifecycleEvents = PublishRelay.create() + private val visibleContractEvents = PublishRelay.create() + private val hiddenContractEvents = PublishRelay.create() + + private val lifecycleEventStream = RxAction.fromObservable { lifecycleEvents } + private val visibleContractEventStream = RxAction.fromObservable { visibleContractEvents } + private val hiddenContractEventStream = RxAction.fromObservable { hiddenContractEvents } + + fun onLifecycleEffect(event: FragmentLifecycleEvent) { + lifecycleEvents.accept(event) + } + + fun onVisibilityChanged(contract: FragmentId, visible: Boolean) { + if (visible) { + visibleContractEvents.accept(contract) + } else { + hiddenContractEvents.accept(contract) + } + } + + override fun initialState(input: FragmentEnvironment): FragmentFlowState = FragmentFlowState() + + override fun Snapshot.evaluate(): Evaluation { + return Evaluation( + output = state, + actions = context.actions { + lifecycleEventStream.onEvent { event -> + val fragmentId = event.fragmentId + when (event) { + is FragmentLifecycleEvent.Removed -> { + val updated = state.copy( + activeIds = state.activeIds.minus(fragmentId), + states = state.states.minus(fragmentId), + features = state.features.minus(fragmentId) + ) + transition(updated) + } + is FragmentLifecycleEvent.Added -> { + if (!state.activeIds.contains(fragmentId)) { + val feature = initFeature(input, fragmentId) + val updated = state.copy( + activeIds = state.activeIds.plus(fragmentId), + features = state.features.plus(feature.id to feature) + ) + transition(updated) + } else { + none() + } + } + } + } + + visibleContractEventStream.onEvent { + if (state.visibleIds.contains(it)) { + // TODO: should we log this duplicate visibility event? + none() + } else { + transition(state.copy(visibleIds = state.visibleIds.plus(it))) + } + } + + hiddenContractEventStream.onEvent { + transition(state.copy(visibleIds = state.visibleIds.minus(it))) + } + + state.features.entries.forEach { entry -> + val fragmentId = entry.key + val feature = (entry.value as? FeatureEvent.Init)?.feature + if (feature != null) { + val action = FeatureObservableAction( + fragmentEnvironment = input, + fragmentId = fragmentId, + feature = feature, + ) + action.onEvent { + if (state.activeIds.contains(fragmentId)) { + val keyState = FragmentState(fragmentId.key, it) + transition(state.copy(states = state.states.plus(fragmentId to keyState))) + } else { + none() + } + } + } + } + } + ) + } + + private fun initFeature( + environment: FragmentEnvironment, + fragmentId: FragmentId, + ): FeatureEvent { + val initialized = try { + bindings.firstNotNullOfOrNull { binding -> + if (binding.type.isInstance(fragmentId.key)) { + val featureFactory = binding.feature as FeatureFactory + val feature = environment.fragmentDelegate.initializeFeature( + fragmentId = fragmentId, + factory = featureFactory, + dependencies = component, + key = fragmentId.key, + ) + FeatureEvent.Init(fragmentId, feature) + } else { + null + } + } + } catch (e: Exception) { + FeatureEvent.Failure(fragmentId, e) + } + + return initialized ?: FeatureEvent.MissingBinding(fragmentId) + } +} \ No newline at end of file diff --git a/formula-android/src/main/java/com/instacart/formula/android/internal/FunctionUtils.kt b/formula-android/src/main/java/com/instacart/formula/android/internal/FunctionUtils.kt deleted file mode 100644 index 14c8adf26..000000000 --- a/formula-android/src/main/java/com/instacart/formula/android/internal/FunctionUtils.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.instacart.formula.android.internal - -internal object FunctionUtils { - fun identity(): (C) -> C { - return { it } - } -} \ No newline at end of file diff --git a/formula-android/src/main/java/com/instacart/formula/android/internal/MappedFeatureFactory.kt b/formula-android/src/main/java/com/instacart/formula/android/internal/MappedFeatureFactory.kt new file mode 100644 index 000000000..ca061e7c2 --- /dev/null +++ b/formula-android/src/main/java/com/instacart/formula/android/internal/MappedFeatureFactory.kt @@ -0,0 +1,18 @@ +package com.instacart.formula.android.internal + +import com.instacart.formula.android.Feature +import com.instacart.formula.android.FeatureFactory +import com.instacart.formula.android.FragmentKey + +@PublishedApi +internal class MappedFeatureFactory( + private val delegate: FeatureFactory, + private val toDependencies: (Component) -> Dependencies, +) : FeatureFactory { + override fun initialize(dependencies: Component, key: Key): Feature { + return delegate.initialize( + dependencies = toDependencies(dependencies), + key = key, + ) + } +} \ No newline at end of file diff --git a/formula-android/src/test/java/com/instacart/formula/android/internal/FunctionUtilsTest.kt b/formula-android/src/test/java/com/instacart/formula/android/internal/FunctionUtilsTest.kt deleted file mode 100644 index 6ed280f0c..000000000 --- a/formula-android/src/test/java/com/instacart/formula/android/internal/FunctionUtilsTest.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.instacart.formula.android.internal - -import com.google.common.truth.Truth -import org.junit.Test - -class FunctionUtilsTest { - - @Test - fun `identity is optimized to return the same value`() { - val stringIdentity = FunctionUtils.identity() - val intIdentity = FunctionUtils.identity() - Truth.assertThat(stringIdentity).isEqualTo(intIdentity) - } -} \ No newline at end of file