Remove LayoutViewFactory + refactor FeatureView to make it compose-first.#511
Conversation
JaCoCo Code Coverage 93.79% ✅
Generated by 🚫 Danger |
| return featureView.view | ||
| val state = mutableStateOf(featureView.initialModel) | ||
| this.outputState = state | ||
| return ComposeView(requireContext()).apply { |
There was a problem hiding this comment.
This changes the framework's error-handling contract for render failures. routeDelegate.setOutput(...) is still wrapped in try/catch, but the actual UI work now happens later inside ComposeView.setContent { ... }, so exceptions thrown during composition/recomposition will bypass RouteEnvironment.onScreenError. The updated test suite explicitly expects that behavior, which means apps that currently rely on onScreenError for screen-level crash reporting or recovery lose it silently. Was that intended? If not, we need a way to preserve the old reporting path for render-time failures before landing this refactor.
There was a problem hiding this comment.
This actually changes nothing for the upstream app, given it was not using LayoutViewFactory. RouteEnvironment.onScreenError was meaningful in LayoutViewFactory since synchronous and imperative android.view.View setup could throw:
- null findViewById
- NPE on a missing field
- adapter blowing up
ComposeViewFactory, on other hand, both before and after the changes, is just changing a MutableState variable, while the actual rendering happens later, inside Compose recomposition, outside
try/catch.
So onScreenError no longer catches the kind of error it was originally designed for. We should think of an alternative approach here, which is out of scope for this PR.
📊 Benchmark Comparison ReportSummary
|
| Benchmark | Baseline | Current | Change |
|---|---|---|---|
| com.instacart.formula.benchmarks.TransitionBenchmark.transitions (depth=0) | 11.479 ± 0.044 us/op | 12.933 ± 0.076 us/op | +12.7% 🔴 |
| com.instacart.formula.benchmarks.ActionCountBenchmark.stateChanges (actionCount=25) | 11.888 ± 0.036 us/op | 13.261 ± 0.119 us/op | +11.5% 🔴 |
| com.instacart.formula.benchmarks.CallbackOverheadBenchmark.transitions (callbackCount=10) | 11.956 ± 0.134 us/op | 13.305 ± 0.141 us/op | +11.3% 🔴 |
| com.instacart.formula.benchmarks.TransitionBenchmark.transitions (depth=10) | 11.785 ± 0.072 us/op | 13.001 ± 0.174 us/op | +10.3% 🔴 |
No significant changes (21 benchmarks)
| Benchmark | Baseline | Current | Change |
|---|---|---|---|
| com.instacart.formula.benchmarks.ActionCountBenchmark.stateChanges (actionCount=1) | 11.554 ± 0.048 us/op | 12.599 ± 0.071 us/op | +9.0% |
| com.instacart.formula.benchmarks.ActionCountBenchmark.stateChanges (actionCount=100) | 12.96 ± 0.158 us/op | 14.08 ± 0.186 us/op | +8.6% |
| com.instacart.formula.benchmarks.ActionInitializationBenchmark.initializeNewActions (actionCount=1) | 0.631 ± 0.058 us/op | 0.686 ± 0.044 us/op | +8.6% |
| com.instacart.formula.benchmarks.ActionInitializationBenchmark.initializeNewActions (actionCount=100) | 11.076 ± 0.156 us/op | 10.684 ± 0.074 us/op | -3.5% |
| com.instacart.formula.benchmarks.ActionInitializationBenchmark.initializeNewActions (actionCount=25) | 3.234 ± 0.034 us/op | 3.089 ± 0.08 us/op | -4.5% |
| com.instacart.formula.benchmarks.CallbackInitializationBenchmark.initializeNewCallbacks (callbackCount=10) | 1.526 ± 0.287 us/op | 1.582 ± 0.279 us/op | +3.7% |
| com.instacart.formula.benchmarks.CallbackInitializationBenchmark.initializeNewCallbacks (callbackCount=50) | 5.519 ± 1.031 us/op | 5.428 ± 0.575 us/op | -1.6% |
| com.instacart.formula.benchmarks.CallbackOverheadBenchmark.transitions (callbackCount=50) | 13.654 ± 0.45 us/op | 14.615 ± 0.527 us/op | +7.0% |
| com.instacart.formula.benchmarks.ChildrenCountBenchmark.stateChanges (childrenCount=1) | 11.585 ± 0.042 us/op | 12.584 ± 0.085 us/op | +8.6% |
| com.instacart.formula.benchmarks.ChildrenCountBenchmark.stateChanges (childrenCount=100) | 13.66 ± 0.182 us/op | 14.965 ± 0.146 us/op | +9.6% |
| com.instacart.formula.benchmarks.ChildrenCountBenchmark.stateChanges (childrenCount=25) | 12.084 ± 0.092 us/op | 13.142 ± 0.305 us/op | +8.7% |
| com.instacart.formula.benchmarks.ChildrenInitializationBenchmark.initializeNewChildren (childrenCount=1) | 0.618 ± 0.04 us/op | 0.688 ± 0.055 us/op | +11.3% |
| com.instacart.formula.benchmarks.ChildrenInitializationBenchmark.initializeNewChildren (childrenCount=100) | 12.28 ± 0.11 us/op | 12.405 ± 1.059 us/op | +1.0% |
| com.instacart.formula.benchmarks.ChildrenInitializationBenchmark.initializeNewChildren (childrenCount=25) | 3.329 ± 0.084 us/op | 3.278 ± 0.046 us/op | -1.5% |
| com.instacart.formula.benchmarks.GlobalEffectQueueBenchmark.measure100Effects | 8.122 ± 0.645 us/op | 8.2 ± 0.185 us/op | +1.0% |
| com.instacart.formula.benchmarks.GlobalEffectQueueBenchmark.measure10Effects | 0.697 ± 0.005 us/op | 0.639 ± 0.069 us/op | -8.3% |
| com.instacart.formula.benchmarks.GlobalEffectQueueBenchmark.measure1Effect | 0.056 ± 0.0 us/op | 0.052 ± 0.001 us/op | -6.8% |
| com.instacart.formula.benchmarks.TransitionBenchmark.transitions (depth=20) | 12.391 ± 0.079 us/op | 13.284 ± 0.073 us/op | +7.2% |
| com.instacart.formula.benchmarks.TransitionQueueBenchmark.measure1 | 0.32 ± 0.016 us/op | 0.339 ± 0.018 us/op | +5.9% |
| com.instacart.formula.benchmarks.TransitionQueueBenchmark.measure100 | 0.324 ± 0.014 us/op | 0.338 ± 0.008 us/op | +4.3% |
| com.instacart.formula.benchmarks.TransitionQueueBenchmark.measure10000 | 0.325 ± 0.004 us/op | 0.347 ± 0.004 us/op | +6.8% |
Regressions: ±10% with non-overlapping confidence intervals. Improvements: ±10% change only.
Generated by 🚫 Danger
|
Should bump to 0.9.0 with this one |
| import com.instacart.formula.android.FeatureView | ||
| import com.instacart.formula.android.ViewFactory | ||
|
|
||
| abstract class ComposeViewFactory<RenderModel : Any> : ViewFactory<RenderModel> { |
There was a problem hiding this comment.
Now, when Compose is first class citizen in formula-android, no need to keep a separate formula-android-compose dependency with a single ComposeViewFactory class. Folding it into formula-android.
| val view: View, | ||
| val setOutput: (RenderModel) -> Unit, | ||
| val content: @Composable (RenderModel) -> Unit, | ||
| val initialModel: RenderModel? = null, |
There was a problem hiding this comment.
Removing unneeded android.view.View field from FeatureView.
| private var featureView: FeatureView<Any>? = null | ||
| private var outputState: MutableState<Any?>? = null |
There was a problem hiding this comment.
No need to keep FeatureView around anymore, for setting new output.
| // Based-on: https://developer.android.com/develop/ui/compose/migrate/interoperability-apis/compose-in-views#compose-in-fragments | ||
| setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) | ||
| setContent { | ||
| state.value?.let { featureView.content(it) } | ||
| } |
There was a problem hiding this comment.
Moving androidx.compose.ui.platform.ComposeView creation from ComposeViewFactory directly to FormulaFragment.
Alternative navigation host (Compose Nav 3) doesn't need ComposeView to render the content.
| return flow<Unit> { delay(5.seconds) }.flatMapLatest { localStore } | ||
| return flow { | ||
| delay(5.seconds) | ||
| emit(Unit) | ||
| }.flatMapLatest { localStore } |
There was a problem hiding this comment.
Fixing unrelated bug that was introduced here: #430
This flow was never emitting, resulting in Tasks app never showing tasks:

| import com.examples.todoapp.R | ||
| import com.instacart.formula.invoke | ||
|
|
||
| private val TodoColors = lightColors( |
There was a problem hiding this comment.
Migrating Tasks demo app from LayoutViewFactory to ComposeViewFactory.
LayoutViewFactory + refactor FeatureView to make it compose-first.
FrancoisBlavoet
left a comment
There was a problem hiding this comment.
nice
the announcement of Android being Compose first is good timing for formula to remove its view integration 🙌
| assertVisibleContract(TestKey()) | ||
| } | ||
|
|
||
| @Test fun `notify fragment environment if setOutput throws an error`() { |
There was a problem hiding this comment.
Yes - TestViewFactory was extending LayoutViewFactory, which propagated view binding exceptions to RouteEnvironment.onScreenError. With ComposeViewFactory, rendering happens within compose recomposition, so we need to think of an alternative mechanism to catch/report rendering errors there.
More in this comment:
#511 (comment)
| */ | ||
| abstract class ComposeViewFactory<RenderModel : Any> : ViewFactory<RenderModel> { | ||
|
|
||
| final override fun create(params: ViewFactory.Params): FeatureView<RenderModel> { |
There was a problem hiding this comment.
I'm not sure if we need FeatureView and ViewFactory separation from ComposeViewFactory. With this change FeatureView already leaks compose APIs into ViewFactory. I'm wondering if we could do the following
Maybe this a little convoluted, but I do see value in keeping an agnostic ViewFactory , but maybe it should be a sealed interface
// Package: com.instacart.formula.android
sealed interface ViewFactory<Output : Any>// Package: com.instacart.formula.android - for sealed hierarchy to work, needs to be in same package.
abstract class ComposeViewFactory<Output : Any> : ViewFactory<Output>// Package: com.instacart.formula.android.compose
@Deprecated()
typealias ComposeViewFactory<Output : Any> = RealComposeViewFactory<Output>There was a problem hiding this comment.
Good point about the leakage. I'm skeptical about agnostic ViewFactory value, given "Android is Compose-first", but I can give this proposal a shot. Either way, would you agree that FeatureView should be eliminated as a redundant abstraction?
There was a problem hiding this comment.
Yeah, I don't think it provides any value anymore.
| * @param view The root Android view. | ||
| * @param setOutput A function called to apply [RenderModel] to the view. | ||
| */ | ||
| class FeatureView<RenderModel>( |
| get() = environment.routeDelegate | ||
|
|
||
| private var featureView: FeatureView<Any>? = null | ||
| private var outputState: MutableState<Any?>? = null |
There was a problem hiding this comment.
Hmm, I'm wondering if some of this logic should be simplified. I don't think we need outputState to be nullable. Maybe we can make it a val and create it on initialization. We could consolidate val output: Any? = null into it.
tryToSetStatemight be redundant.
In the onCreateView, we could have
val initial: Any? = when (viewFactory) {
is ComposeViewFactory -> viewFactory.initialModel()
}
setContent {
val output = outputState.value ?: initial
if (output != null) {
routeDelegate.Content(formulaRouteId, viewFactory, output)
}
}There was a problem hiding this comment.
Great suggestion - done



Remove
LayoutViewFactory+ refactorFeatureViewto make it compose-first.Also, as a side effect of
LayoutViewFactoryremoval:formula-android-composemodule intoformula-androidWhy do we stop supporting Android Views?
Android is Compose-first