问题出现的场景
- 需求:使用
Robolectric或Espresso和mockk对Fragment进行单元测试 - 技术栈:MVVM、Fragment中
Hilt依赖注入和Jetpack Navigation页面跳转 - 差异点:ViewModel使用
provideGraphViewModel绑定一个graph id懒加载 - 原因: 在Fragment的
onCreate()方法中使用ViewModel造成ViewModel在Fragment的view创建之前就调用了findNavController()
FragmentTest代码
@HiltAndroidTest
@RunWith(RobolectricTestRunner::class)
@Config(
sdk = [Build.VERSION_CODES.O_MR1],
application = HiltTestApplication::class
)
class XXXFragmentTest {
@get:Rule
var hiltRule = HiltAndroidRule(this)
private val navController = mockk<NavController>(relaxed = true)
@Before
fun setUp() {
hiltRule.inject()
}
@Test
fun testSomeButtonClick() {
launchFragmentInHiltContainer<XXXFragment>(fragmentArgs = bundle) {
ShadowLooper.runUiThreadTasks()
// The fragment’s view has just been created
Navigation.setViewNavController(requireView(), navController) // 向Fragment中注入Mock对象
view?.findViewById<ImageButton>(R.id.ivActionBarBack)?.performClick()
verify { findNavController().navigateUp() } // 获取Fragment#NavController并校验`navigateUp()`方法的调用
}
}
}
launchFragmentInHiltContainer的用法具体参考Hilt 测试指南(需要科学上网)
代码如下:
inline fun <reified T : Fragment> launchFragmentInHiltContainer(
fragmentArgs: Bundle? = null,
@StyleRes themeResId: Int = R.style.FragmentScenarioEmptyFragmentActivityTheme,
crossinline action: Fragment.() -> Unit = {}
) {
val startActivityIntent = Intent.makeMainActivity(ComponentName(ApplicationProvider.getApplicationContext(), HiltTestActivity::class.java))
.putExtra("androidx.fragment.app.testing.FragmentScenario.EmptyFragmentActivity.THEME_EXTRAS_BUNDLE_KEY", themeResId)
ActivityScenario.launch<HiltTestActivity>(startActivityIntent).onActivity { activity ->
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()
}
}
该方法具体实现就是使用一个HiltActivity加载目标Fragment,并在action()回调里执行test代码
调试过程
Fragment XXXFragment does not have a NavController set
at androidx.navigation.fragment.NavHostFragment$Companion.findNavController(NavHostFragment.kt:394)
at androidx.navigation.fragment.FragmentKt.findNavController(Fragment.kt:29)
at com.income.travel.declaration.XXXFragment$viewModel$2$invoke$$inlined$provideGraphViewModel$1.invoke(HiltNavGraphViewModelLazy.kt:49)
源码定位:
@MainThread
public inline fun <reified VM : ViewModel> Fragment.hiltNavGraphViewModels(@IdRes navGraphId: Int): Lazy<VM> {
val backStackEntry by lazy {
findNavController().getBackStackEntry(navGraphId) // 原因就是在这里调用了findNavController()
}
val storeProducer: () -> ViewModelStore = {
backStackEntry.viewModelStore
}
return createViewModelLazy(VM::class, storeProducer) {
HiltViewModelFactory(requireActivity(), backStackEntry)
}
}
在Fragment#onCreate() 方法中调用了ViewModel里的方法,从而造成ViewModel初始化的过程中调用了findNavController()方法
更加深入我们会发现具体的报错位置:
@JvmStatic
public fun findNavController(fragment: Fragment): NavController {
var findFragment: Fragment? = fragment
while (findFragment != null) {
if (findFragment is NavHostFragment) {
return findFragment.navHostController as NavController
}
val primaryNavFragment = findFragment.parentFragmentManager
.primaryNavigationFragment
if (primaryNavFragment is NavHostFragment) {
return primaryNavFragment.navHostController as NavController
}
findFragment = findFragment.parentFragment
}
// Try looking for one associated with the view instead, if applicable
val view = fragment.view
if (view != null) {
return Navigation.findNavController(view)
}
// For DialogFragments, look at the dialog's decor view
val dialogDecorView = (fragment as? DialogFragment)?.dialog?.window?.decorView
if (dialogDecorView != null) {
return Navigation.findNavController(dialogDecorView)
}
throw IllegalStateException("Fragment $fragment does not have a NavController set") // 具体报错位置
}
因为是在HiltActivity里直接用FragmentManager加载目标Fragment,所以在Fragment#view创建之前调用findNavController()的话就会报错
解决方案
方法一:把ViewModel的方法调用放到onCreateView()之后调用并提前注入NavController的Test或Mock对象
inline fun <reified T : Fragment> launchFragmentInHiltContainer(
fragmentArgs: Bundle? = null,
themeResId: Int = R.style.FragmentScenarioEmptyFragmentActivityTheme,
fragmentFactory: FragmentFactory? = null,
navController: NavController? = null,
crossinline action: T.() -> Unit = {}
) {
val mainActivityIntent = Intent.makeMainActivity(ComponentName(ApplicationProvider.getApplicationContext(), HiltTestActivity::class.java))
.putExtra(THEME_EXTRAS_BUNDLE_KEY, themeResId)
ActivityScenario.launch<HiltTestActivity>(mainActivityIntent).onActivity { activity ->
fragmentFactory?.let {
activity.supportFragmentManager.fragmentFactory = it
}
val fragment = activity.supportFragmentManager.fragmentFactory.instantiate(Preconditions.checkNotNull(T::class.java.classLoader), T::class.java.name)
fragment.arguments = fragmentArgs
fragment.viewLifecycleOwnerLiveData.observeForever { viewLifecycleOwner ->
if (viewLifecycleOwner != null) {
navController?.let { Navigation.setViewNavController(fragment.requireView(), it) }
}
}
activity.supportFragmentManager.beginTransaction()
.add(android.R.id.content, fragment, "")
.commitNow()
(fragment as T).action()
}
}
方法二:参考Navigation的实现,HiltActivity和Fragment之间加一层NavHostFragment的container
具体改造代码如下:
inline fun <reified T : Fragment> launchFragmentInHiltHostContainer(
fragmentArgs: Bundle? = null,
graphId: Int,
destinationId: Int,
themeResId: Int = R.style.FragmentScenarioEmptyFragmentActivityTheme,
crossinline action: T.() -> Unit = {}
) {
val mainActivityIntent = Intent.makeMainActivity(ComponentName(ApplicationProvider.getApplicationContext(), HiltTestActivity::class.java))
.putExtra(THEME_EXTRAS_BUNDLE_KEY, themeResId)
ActivityScenario.launch<HiltTestActivity>(mainActivityIntent).onActivity { activity ->
val hostFragment: NavHostFragment = activity.supportFragmentManager
.fragmentFactory.instantiate(Preconditions.checkNotNull(NavHostFragment::class.java.classLoader), NavHostFragment::class.java.name) as NavHostFragment
hostFragment.viewLifecycleOwnerLiveData.observeForever {
when (it.lifecycle.currentState) {
Lifecycle.State.RESUMED -> {
val graphInflater = hostFragment.navController.navInflater
val navGraph: NavGraph = graphInflater.inflate(graphId)
navGraph.addInDefaultArgs(fragmentArgs)
val navController = hostFragment.navController
navGraph.setStartDestination(destinationId)
navController.graph = navGraph
navController.addOnDestinationChangedListener { _, destination, _ ->
if(destination.id == destinationId){
val fragment = hostFragment.childFragmentManager.fragments[0]
(fragment as T).action()
}
}
}
else -> Unit
}
}
activity.supportFragmentManager.beginTransaction()
.add(android.R.id.content, hostFragment, "")
.commitNow()
}
}
其他可参考 issue android/architecture-samples#752