Custom Metro-Hilt Interop

Custom Metro-Hilt Interop

I have been researching Metro for a while now and I am in love with it. All of the build speed and runtime performance improvements reported by the companies that migrate and the beautiful syntax won me over quick, and I was curious what improvements it would have over a project that used Hilt, so I started by converting the famous "NowInAndroid" app from Hilt to Metro and did some build speed measurements which I happily reported back to Zac.

But then a problem arose as I compared startup times. It was 11% slower, and in my build, startup times are king 😦.

Zac pushed a fix before end of day though making startup equivalent to other DI (because apparently he is superman) and he developed tests and infrastructure to make sure there were no buildtime/runtime regressions. Recently he pushed the equivalent of dagger's fastInit and people are reporting that now Metro has faster startups than other DI frameworks.

I was sold, and I started pitching Metro to my guild, but in order to get buy-in to migrate the repo to Metro, I first had to make a series of presentations to the "Android Consuls Group". They were impressed with everything, but they had one big hang up - a complete overhaul of the repo sounded like too heavy a lift, so I started researching how other companies were migrating, and I noticed that the companies I saw migrating fell into two camps:

  1. Large Anvil repos that gradually migrated using a Gradle property to switch between builds
  2. Small Hilt repos that do a complete overhaul.

I liked the idea of switching on a Gradle property because it would really go far in getting the consuls to approve my plan, however switching between Hilt and Metro builds had some extra considerations. In Hilt, lifecycle plays a heavy role in the state of the things it injects because of destroying/recreating modules and the special scopes it does it in. Also, Hilt can inject Android-ish things like Context and Activity.

I experimented for a few days and I came up with a plan which I will detail at the end. Once I got it working, I reported the following:

My friend Inaki at Gradle profiled my Metro-Hilt solution.

It was definitely faster, but I felt it had lost some performance due to all the boiler plate and additional code that was required.

The main idea behind my plan for metro-hilt interop is 4 metrox artifacts.  I was hoping that Zac would consider canonizing/supporting them, but I don't think he has much interest in Hilt.

Forked repo for reference: https://github.com/JohnBuhanan/nowinandroid

1. metrox-hilt-gradle

    1. Gradle config for “useMetroxHilt” Gradle property
    2. Switch between Metro/Hilt plugin and dependencies.
    3. Switch between metro/hilt sourcesets
    4. useMetroxHilt=true -> 
      1. Setup build/manifest as an additional source of AndroidManifest.xml that we will generate later
      2. Gradle task GenerateMetroGraphTask
        1. This could be converted to a Metro extension maybe(?)
        2. Any compile app task will generate a barebones MetroGraph.kt in main.  
        3. Any androidTest run will generate a MetroGraph.kt in androidTest 
        4. Generate an AndroidManifest.xml that says to use GeneratedMetroAppComponentFactory as the AppComponentFactory.
          1. Calls createGraph<MetroGraph>() 
          2. Injects the currently defined Application class.
          3. Injects every Activity that gets created from now on.
        5. Also I am concerned about too much work being done when a MetroGraph exists in multiple places.)

2. metrox-hilt-runtime

    1. Contains all of the metro graph extensions + scopes that are analogs of Hilt components.
      1. SingletonComponent -> AppScope + AppGraph
      2. ActivityRetainedComponent -> ActivityRetainedScope + ActivityRetainedGraph
      3. ActivityComponent -> ActivityScope + ActivityGraph
      4. ViewModelComponent -> ViewModelScope + ViewModelGraph
      5. FragmentComponent -> FragmentScope + FragmentGraph
      6. TODO: ViewComponent, ViewWithFragmentComponent
    2. Also contains factories for ViewModels, Fragments, and Workers
    3. I haven’t tried injecting BroadcastReceiver or Service. Not sure if any classes needed here.
    4. We should make a module in metrox-hilt-runtime that lets Hilt know that it’s okay to not provide a ViewModelProvider.Factory.  I currently hard code it.
@Module
@InstallIn(ActivityComponent::class)
interface OptionalViewModelProviderModule {
   @BindsOptionalOf
   fun bindOptionalFoo(): ViewModelProvider.Factory
}

3. metrox-hilt-compose

    1. Wraps hiltViewModel() and metroViewModel() into injectedViewModel() which allows switching between DI types. (injection, assisted injection, and manual assisted injection)

4. metrox-hilt-testing

    1. Contains MetroTestApplication, InjectedRule, MetroTest, MetroTestRunner, and TestAppGraph/TestAppScope
      1. MetroTestApplication exposes the AppGraph that is used by InjectedRule to create TestAppGraph
      2. InjectedRule wraps HiltRule and MetroRule which allows switching between DI types.

5. @Module Hilt modules:

    1. Add “@ContributesTo” all Hilt modules matching their scope to Metro analog, e.g.-
@Module
@InstallIn(SingletonComponent::class)
@ContributesTo(AppScope::class)
internal abstract class FooModule {
   @Binds
   abstract fun bindsFoo(impl: FooImpl): Foo
}

6. @HiltAndroidApp Applications (member injection)

    1. Add a module either directly above the Application class or put it in the /metro sourceset in same module.  (this boilerplate could be a metro extension instead I think)
@ContributesTo(AppScope::class)
interface FooApplicationModule {
   @Binds
   @IntoMap
   @ClassKey(FooApplication::class)
   fun bindsFooApplication(instance: MembersInjector<FooApplication>): MembersInjector<Application>
}

7. @AndroidEntryPoint Activities (member injection)

    1. Add a module either directly above the Activity class or put it in the /metro sourceset in same module. (This boilerplate could be a Metro extension instead I think.)
@ContributesTo(ActivityScope::class)
interface MainActivityModule {
   @Binds
   @IntoMap
   @ClassKey(MainActivity::class)
   fun bindsMainActivity(instance: MembersInjector<MainActivity>): MembersInjector<Activity>
}
  1. If a ViewModel is somewhere down the chain from this Activity then add these lines to this Activity.
@AndroidEntryPoint
class FooActivity : ComponentActivity() {
   // Conditionally injected only when useMetro=true
   @Inject
   lateinit var metroViewModelFactory: Optional<ViewModelProvider.Factory>
   override val defaultViewModelProviderFactory: ViewModelProvider.Factory get() = metroViewModelFactory.getOrDefault(super.defaultViewModelProviderFactory)
  1. @AndroidEntryPoint Fragments (member injection):
    1. Add a module either directly above the Fragment class or put it in the /metro sourceset in same module. (This boilerplate could be a Metro extension instead I think.)
@ContributesTo(AppScope::class)
interface FooFragmentModule {
   @Binds
   @IntoMap
   @ClassKey(FooFragment::class)
   fun bindsFooFragment(instance: MembersInjector<FooFragment>): MembersInjector<Fragment>
}
    1. Fragments will use their hosting Activities ViewModelProvider.Factory, so no need to override at the Fragment level.
  1. @HiltViewModel ViewModels (constructor injection via MetroViewModelFactory set to hosting Activity via injection):
    1. Add these additional Annotations.
@ContributesIntoMap(ViewModelScope::class)
@ViewModelKey(FooViewModel::class)
@HiltViewModel
class FooViewModel @Inject constructor(
   private val dep1: Dep1,
   dep2: Dep2,
) : ViewModel() { ... }
    1. AssistedInjection in ViewModels: 
      1. Hilt could struggle to process a Metro ViewModel Factory if it sees one due to a bug Hilt has with generics that Metro annotations would trigger.
      2. Potentially we would have to move the Metro and Hilt factories out of their ViewModel and put them into their respective hilt/metro sourcesets.
    2. For Compose ViewModels, swap all calls of hiltViewModel() to injectViewModel(). This is a wrapper that will use either metroViewModel or hiltViewModel depending on useMetroxHilt Gradle property.

10. @HiltWorker Workers:

    1. I use a Configuration.Provider approach to switch between Metro and Hilt. I will have to explore the different ways that Workers are initialized in order for a more comprehensive solution maybe?
    2. This allowed me to do away with EntryPointAccessor for Workers

11. @HiltAndroidTest Tests in /androidTest

    1. Add a module either directly above the Test class or put it in the /metro sourceset in same module/androidTest. (This boilerplate could be a Metro extension instead I think.)
@ContributesTo(TestAppScope::class)
interface FooTestModule {
   @Binds
   @IntoMap
   @ClassKey(FooTest::class)
   fun bindsFooTest(instance: MembersInjector<FooTest>): MembersInjector<MetroTest>
}

12. @HiltAndroidTest Robolectric Tests in /test (TODO)

    1. I ran out of time, but since we can’t use MetroAppComponentFactory, we would need to make a second TestRunner (MetroRobolectricTestRunner?) that instantiates the graphs the same that MetroAppComponentFactory would just for these unit tests.  I think the logic can be shared and that it’s doable. 
    2. We would also need to add a module either directly above the Test class or put it in the /metro sourceset in same module/test. (This boilerplate could be a Metro extension instead I think.)
@ContributesTo(TestAppScope::class)
interface FooTestModule {
   @Binds
   @IntoMap
   @ClassKey(FooTest::class)
   fun bindsFooTest(instance: MembersInjector<FooTest>): MembersInjector<MetroTest>
}

13. Metro does not magically bridge modules the way that Hilt does. Scenarios like A -> B -> C will require A to have dependencies on both B and C, so we will have to fix some of our dependencies.

  1. Random “EntryPointAccessors” usages should be able to be replaced with directly accessing things from the given Metro graphs.