Skip to content

Remove LayoutViewFactory + refactor FeatureView to make it compose-first.#511

Merged
Laimiux merged 8 commits into
masterfrom
bezverhni/feature_view_refactor
May 20, 2026
Merged

Remove LayoutViewFactory + refactor FeatureView to make it compose-first.#511
Laimiux merged 8 commits into
masterfrom
bezverhni/feature_view_refactor

Conversation

@alexanderbezverhni

@alexanderbezverhni alexanderbezverhni commented May 19, 2026

Copy link
Copy Markdown
Collaborator

Remove LayoutViewFactory + refactor FeatureView to make it compose-first.

Also, as a side effect of LayoutViewFactory removal:

  • update Tasks sample app to use Compose
  • fold formula-android-compose module into formula-android

Why do we stop supporting Android Views?

Android is Compose-first

@alexanderbezverhni alexanderbezverhni self-assigned this May 19, 2026
@carrotkite

carrotkite commented May 19, 2026

Copy link
Copy Markdown
1 Warning
⚠️ No coverage data found for com/instacart/formula/android/ViewFactory

JaCoCo Code Coverage 93.79% ✅

Class Covered Meta Status
com/instacart/formula/android/ViewFactory No coverage data found : -% No coverage data found : -% 🃏
com/instacart/formula/android/RouteEnvironment 100% 0%
com/instacart/formula/android/FeatureFactory 100% 0%
com/instacart/formula/android/FormulaFragment 50% 0%
com/instacart/formula/android/ComposeViewFactory 100% 0%

Generated by 🚫 Danger

return featureView.view
val state = mutableStateOf(featureView.initialModel)
this.outputState = state
return ComposeView(requireContext()).apply {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@alexanderbezverhni alexanderbezverhni May 19, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@carrotkite

carrotkite commented May 19, 2026

Copy link
Copy Markdown
1 Warning
⚠️ ⚠️ Performance regressions detected in benchmarks

📊 Benchmark Comparison Report

Summary

  • Regressions: 4 ⚠️
  • Improvements: 0
  • Unchanged: 21

⚠️ Performance Regressions

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

@Jawnnypoo

Copy link
Copy Markdown
Member

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> {

@alexanderbezverhni alexanderbezverhni May 19, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +17 to +15
val view: View,
val setOutput: (RenderModel) -> Unit,
val content: @Composable (RenderModel) -> Unit,
val initialModel: RenderModel? = null,

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removing unneeded android.view.View field from FeatureView.

Comment on lines +40 to +44
private var featureView: FeatureView<Any>? = null
private var outputState: MutableState<Any?>? = null

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No need to keep FeatureView around anymore, for setting new output.

Comment on lines +58 to +62
// 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) }
}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines -24 to +27
return flow<Unit> { delay(5.seconds) }.flatMapLatest { localStore }
return flow {
delay(5.seconds)
emit(Unit)
}.flatMapLatest { localStore }

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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(

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Migrating Tasks demo app from LayoutViewFactory to ComposeViewFactory.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Few screenshots AFTER:

1 2 3
Image Image Image

@alexanderbezverhni alexanderbezverhni marked this pull request as ready for review May 19, 2026 21:26
@alexanderbezverhni alexanderbezverhni changed the title Remove LayoutViewFactory + refactor FeatureView to make it compose-first. Remove LayoutViewFactory + refactor FeatureView to make it compose-first. May 19, 2026

@FrancoisBlavoet FrancoisBlavoet left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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`() {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not valid test anymore?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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> {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So long!

get() = environment.routeDelegate

private var featureView: FeatureView<Any>? = null
private var outputState: MutableState<Any?>? = null

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

  • tryToSetState might 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)
          }
      }

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great suggestion - done

@Laimiux Laimiux left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks great

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants