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
inFragmentManager
FragmentFactroy
rescue us from TestingFragment
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.
...
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)
The AndroidX new API FragmentScenario
lets use an isolation test of Fragment. Cooool!! Letâs run!
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.
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)
Now, injected FragmentFactoy
will intercept the instantiating Fragment process and give our Fragment having constructor injection.
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
.
- Implement
HasAndroidInjector
- 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!
- Github
- Website
- Medium Blog, Dev Blog, Naver Blog
- Contact: mym0404@gmail.com