Fancy Fragment = FragmentFactory+Dagger

MJ Studio
MJ Studio
Published in
8 min readDec 9, 2019

--

TL;DR

  • AndroidX Fragment new Features: FragmentFactory & FragmentContainerView
  • Traditionally, we had to declare no-args default constructor of Fragment because of Android system behavior
  • Now, you can override the above behavior with setting FragmentFactory in FragmentManager
  • FragmentFactroy rescue us from Testing Fragment Hell
  • Accordingly, we can enjoy the constructor dependencies injection feature of Dagger Bingo!!
  • Multibindings of Dagger helps us from re-declaration of FragmentFactory

Also, you can skip my boring posting to go direct to code! Enjoy it!

Recently, I found an interesting new AndroidX feature in Fragment. They are FragmentFactory and FragmentContainerView. These classes are added to Jetpack Fragment release for flexible usages of Fragment. First, I won’t address FragmentContainerView deeply in this article(This also has high compatibility with legacy code and can be easily understood for migrating your existing project). The aim of this article is why FragmentFactory is valuable and usage of that! Are you ready?

Recommended Watching

FragmentContainerView

So far FrameLayout has been used to add/replace Fragment with FragmentManager.

<FrameLayout
android:id="@+id/frameLayoutContainer"
...
/>

Then, we can use this container in code for feeding FragmentManager.

I will show our MainFragment implementation later because it is not primary now.

Or, we just have used <fragment> tag for show our fragment.

<fragment
android:id="@+id/myFragment"
android:name="..."
...
/>

We can use the new FragmentContainerView class for the substitution of the above. The benefits get using FragmentContainerView is fix the z-index bug in Fragments. It automatically handles transition animation between Fragments correctly.

Ok, then how can we substitute FrameLayout or <fragment> tag to FragmentContainerView? Let me show a simple example! 🕶🕶

1. FrameLayout to FragmentContainerView (FragmentManager)

<FrameLayout
android:id="@+id/frameLayoutContainer"
... />

to

<FragmentContainerView
android:id="@+id/fragmentContainerView"
... />

Great. There is no difference except just a few characters.

The source code doesn’t need updating. We happily can use the same code before. This is the reason why I said the compatibility of FragmentContainerView is awesome.

2. <fragment> to FragmentContainerView (Embedded way)

As you may have noticed, replacement <fragment> to FragmentContainerView is not differencing with above.

<fragment
android:id="@+id/fragmentTagContainer"
android:name="happy.mjstudio.fragmentfactorydagger.MainFragment"
tools:layout="@layout/fragment_main"
... />

to

<FragmentContainerView
android:id="@+id/fragmentContainerViewEmbed"
android:name="happy.mjstudio.fragmentfactorydagger.MainFragment"
tools:layout="@layout/fragment_main"
... />

Yes! now, we can use Fragment fancier than the past. The above substitution is an easy one. In the real world, we must consider more complex usage of our Fragment. Let’s dive into that world!✈️ Don’t be afraid, a Hero is coming soon.

Difficulty of Fragment

1. Fragment no-args default constructor

There are such situations Fragment is recreated in Android Lifecycle. Configuration changing(rotation, locale etc…) and manual add/replace Fragment to our screen are that. At that moment, the Android system uses the default constructor(no-args constructor) of Fragment internally for the re-creation of Fragment. There is the reason that we must not change the default constructor of Fragment(primary constructor in Kotlin). Yes, it is related to .newInstance(args…) syntax in Fragment creation. What happens if we change the default constructor of Fragment the following?

class MainFragment(private val constructorArg : String) : Fragment(){
...

Ok… the compiler doesn’t show any error or warning about the above change. Let’s test on runtime.

boom!
...
Caused by:
androidx.fragment.app.Fragment$InstantiationException: Unable to instantiate fragment happy.mjstudio.fragmentfactorydagger.MainFragment: could not find Fragment constructor
...Caused by:
java.lang.NoSuchMethodException: happy.mjstudio.fragmentfactorydagger.MainFragment.<init> []
...

Well… stack trace described that instantiating MainFragment is failed due to missing default constructor! If you add Fragment via code at first and forget handling configuration change situations, it will be able to a horrible one.☠

2. Hard Testability

In dependency injection principle, the constructor injection is better than the field injection strategy. The main reason for this is its testability. If Fragment possesses its dependencies from the constructor, then mocking or stubbing instances is easier than field injection. Also, the AndroidX team added FragmentScenario class to their library. FragmentScenario is similar to ActivityScenario. We can launch and test our Fragment in a completely isolation situation(an empty FragmentActivity). Imagine following Fragment code.

Let’s test with FragmentScenario.

Field Injection(Hacky)

wrong⚠️

The AndroidX new API FragmentScenario lets use an isolation test of Fragment. Cooool!! Let’s run!

boom again!

Ok… What is the problem???

java.lang.RuntimeException: java.lang.IllegalArgumentException: No injector factory bound for Class<happy.mjstudio.fragmentfactorydagger.MainFragment>

The error message noticed that the missing injection of AndroidInjector<MainFragment>. AndroidInjector is a new helper class in Dagger 2 for injecting Android components(Activity, Fragment etc…). and Classes having prefix ‘Dagger’ is injected their dependencies by AndroidInjector automatically without any manual code! It really feels like magic. But in test time, it makes us in hell. How can we solve this problem? There are such ways to do it but no formal one.

First, I created TestAppComponent, and Fragment Contribution module for contributing AndroidInjector<MainFramgent> to our Dagger dependency graph(hacky!).

This way solved our problem.

test passed!

But, It makes some boilerplate code and we always have not time to write test code(Don’t do think really like this 😁).

Constructor Injection(Idle)

Imagine our Fragment can be injected dependencies with constructor injection like the following.

We don’t need to provide AndroidInjector<MainFragment> for field injection and our TestComponent can be more simple like one used in the Unit test. But this can’t be done ourselves. We need our new superpower companion. Yes, FragmentFactory❤️!

FragmentFactory

FragmentFactory is similar to ViewModelProvider.Factor . We provide the ways instantiating our Fragment to FragmentFactory early and register FragmentFactory to FragmentManager. Then, when the situation that Fragment has to be re-instantiated, FragmentFactory handles the instantiation of Fragment. Let’s explore our first FragmentFactory!

We extended FragmentFactoy class and overrided instantiate method. It’s time to inject our first FragmentFactory with Dagger.

We should set fragmentFactory field in FragmentManager. In Activity(AppCompatActivity maybe), we can reference supportFragmentManager for that.

*Note: FragmentFactory has to be set before call super.onCreate(savedInstanceState)

wrong⚠️

Now, injected FragmentFactoy will intercept the instantiating Fragment process and give our Fragment having constructor injection.

boom again and again
Caused by: kotlin.UninitializedPropertyAccessException: lateinit property fragmentFactory has not been initialized
...

Ok… why our fragmentFactory is not injected???

This is not about Dagger. This is the expected behavior. The problem is timing!

In this case, I didn’t use DaggerAppCompatActivity because Dagger’s ‘Dagger’ prefix Android component classes are injected at a given time. In Activity, that is super.onCreate() . The definition of DaggerAppCompatActivity shows that.

But, we can benchmark the above pattern for our MainActivity.

  1. Implement HasAndroidInjector
  2. Call AndroidInjection.inject(this) method.

But in our Activity, the time to call step 3 will be prior to super.onCreate()

The changed snippet has some old-style boilerplate code… But it is not bad😎.

Ok. the hero is coming, don’t hesitate to test your Fragment!

Our TestAppComponent is simpler than before. I renamed my Component remove App infix because there is no need to inject AndroidInjector of Fragment! Cool🙌

And, our FragmentTest! We can use our injected FragmentFactory as a parameter of launchFragmentInContainer! Then, FragmentScenario use FragmentFactory when instantiating Fragment.

Even simper, we can write the test code without Dagger like the following.

private val fragmentFactory = OurFragmentFactory(...)

But, I assumed a situation that our dependency graph is more complex for a real-world test.

Last test! Let’s test Fragment recreation situation(Configuration Change)

The application is running correctly even if our Fragment hasn’t a no-args default constructor! Our problems with Fragment is completely solved😍😍😍

Bonus 1. Fragment Dynamic Arguments

Now, we know how to inject our static dependencies to our Fragment with constructor injection. Then, how can we transmit our dynamic arguments to Fragment like before?

In add/replace methods of FragmentManager, we can pack Bundle and send it as a parameter! It is not difficult. You can also write your helper method for construct Bundle from Fragment.

class MainFragment @Inject constructor(private val myDependency: Dependency) : Fragment() {    companion object {        private const val ARG_STRING = "ARG_STRING"        fun newArgBundle(arg : String) = Bundle().apply {
putString(ARG_STRING,arg)
}
}
private val arg : String by lazy { arguments?.getString(ARG_STRING) ?: "No Arg" }

Bonus 2. MultiBindings for Fragments

Ok, we can use Dagger’s great feature named Multibindings for FragmentFactory like ViewModelProvider.Factory. If you are not familiar with Multibindings, you can read here for a simple tutorial!

We should refactor little code in FragmentFactory and FragmentFactoryModule too.

Awesome! From now, we can add binding to any other new Fragment for FragmentFactory. This means we need only one FragmentFactory class for handling the whole Fragment instantiating process.

We explored new AndroidX Fragment API FragmentContainerView, FragmentFactoy, FragmentSceinario with Dagger! It’s time to use our Fragment more fancy way than past. Evolve your code now😊 Good bye!

— Update 2020. 7. 13 —

Bonus 3. FragmentFactory using Hilt

If you are using the new Android dependency injection library Hilt, the following approach is a valid one. There is @EntryPoint annotation in FragmentFactory.

class MainFragmentFactory private constructor(app: Application) : FragmentFactory() {

@EntryPoint
@InstallIn(ApplicationComponent::class)
interface MainFragmentFactoryEntryPoint {
fun pixelRatio(): PixelRatio
}

private val entryPoint = EntryPointAccessors.fromApplication(app, MainFragmentFactoryEntryPoint::class.java)

override fun instantiate(classLoader: ClassLoader, className: String): Fragment {
return when (loadFragmentClass(classLoader, className)) {
MainFragment::class.java -> MainFragment()
WritingFragment::class.java -> WritingFragment()
TodayStarFragment::class.java -> TodayStarFragment(entryPoint.pixelRatio())
SignInFragment::class.java -> SignInFragment()
else -> super.instantiate(classLoader, className)
}
}

companion object {
private var instance: MainFragmentFactory? = null

fun getInstance(app: Application): MainFragmentFactory {
return instance ?: run {
instance = MainFragmentFactory(app)
instance!!
}
}
}
}

And, get a factory instance in your Activity .

@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
private lateinit var mainFragmentFactory: MainFragmentFactory

override fun onCreate(savedInstanceState: Bundle?) {
mainFragmentFactory = MainFragmentFactory.getInstance(application)
supportFragmentManager
.fragmentFactory = mainFragmentFactory
super.onCreate(savedInstanceState)
setContentView(layout.activity_main)
}
}

If you want to test your Fragment annotated using launchFragmentInContainer with @AndroidEntryPoint , then your test will be failed due to that the wrapper default Activity for launchFragmentInContainer is not annotated with @AndroidEntryPoint .

This can be resolved with the following utility function called launchFragmentInHiltContainer .

/**
* launchFragmentInContainer from the androidx.fragment:fragment-testing library
* is NOT possible to use right now as it uses a hardcoded Activity under the hood
* (i.e. [EmptyFragmentActivity]) which is not annotated with @AndroidEntryPoint.
*
* As a workaround, use this function that is equivalent. It requires you to add
* [HiltTestActivity] in the debug folder and include it in the debug AndroidManifest.xml file
* as can be found in this project.
*/
inline fun <reified T : Fragment> launchFragmentInHiltContainer(
factory: FragmentFactory,
fragmentArgs: Bundle? = null,
@StyleRes themeResId: Int = R.style.FragmentScenarioEmptyFragmentActivityTheme,
crossinline action: Fragment.() -> Unit = {}
) {
val startActivityIntent = Intent.makeMainActivity(
ComponentName(
ApplicationProvider.getApplicationContext<Application>(), HiltTestActivity::class.java
)
)
.putExtra(EmptyFragmentActivity.THEME_EXTRAS_BUNDLE_KEY, themeResId)

ActivityScenario.launch<HiltTestActivity>(startActivityIntent).onActivity { activity ->
activity.supportFragmentManager.fragmentFactory = factory
val fragment: Fragment = activity.supportFragmentManager.fragmentFactory.instantiate(
Preconditions.checkNotNull(T::class.java.classLoader), T::class.java.name
)
fragment.arguments = fragmentArgs
activity.supportFragmentManager.beginTransaction().add(android.R.id.content, fragment, "").commitNow()

fragment.action()
}
}

Then, you have to add debug source set and create test Activity for testing and add it to AndroidManifest.xml

Done!

--

--