architecture-samples
android/architecture-samples
是google官方的sample项目:TODO-APP,通过其可以学习androidx中各种组件的使用。其中dev-hilt
分支展示了Dagger Hilt的使用:
https://github.com/android/architecture-samples/tree/dev-hilt(hash:f2fd9ce969a431b20218f3ace38bbb95fd4d1151),
除了Hilt的使用,还可以了解一下如何使用Hilt进行单元测试。
一个Hilt项目通常会涉及以下Component,本文介绍几个最主要的Component以及其单元测试的方法
图片引自https://developer.android.com/training/dependency-injection/hilt-android
ApplicationComponent
为Application添加@HiltAndroidApp
注解,将其关联到ApplicationComponent
@HiltAndroidApp
class TodoApplication : Application() {
override fun onCreate() {
super.onCreate()
if (BuildConfig.DEBUG) Timber.plant(DebugTree())
}
}
通过@InstallIn
,将AppModule和TasksRepositoryModule安装到ApplicationComponent
ApplicationComponent
- AppModule
- TasksRepositoryModule
@Module
@InstallIn(ApplicationComponent::class)
object AppModule {
...
}
/**
* The binding for TasksRepository is on its own module so that we can replace it easily in tests.
*/
@Module
@InstallIn(ApplicationComponent::class)
object TasksRepositoryModule {
...
}
AppModule用来提供多种TasksDataSource
,例如LocalTasksDataSource、LocalTasksDataSourced等。
使用@Qualifier
元注解用来区分不同的DataSource类型。
@Module
@InstallIn(ApplicationComponent::class)
object AppModule {
@Qualifier
@Retention(RUNTIME)
annotation class RemoteTasksDataSource
@Qualifier
@Retention(RUNTIME)
annotation class LocalTasksDataSource
...
@Singleton
@LocalTasksDataSource
@Provides
fun provideTasksLocalDataSource(
database: ToDoDatabase,
ioDispatcher: CoroutineDispatcher
): TasksDataSource {
return TasksLocalDataSource(
database.taskDao(), ioDispatcher
)
}
...
}
TasksRepositoryModule中provideTasksRepository
,通过RemoteTasksDataSource等注解,就可以注入TasksDataSource了
/**
* The binding for TasksRepository is on its own module so that we can replace it easily in tests.
*/
@Module
@InstallIn(ApplicationComponent::class)
object TasksRepositoryModule {
@Singleton
@Provides
fun provideTasksRepository(
@AppModule.RemoteTasksDataSource remoteTasksDataSource: TasksDataSource,
@AppModule.LocalTasksDataSource localTasksDataSource: TasksDataSource,
ioDispatcher: CoroutineDispatcher
): TasksRepository {
return DefaultTasksRepository(
remoteTasksDataSource, localTasksDataSource, ioDispatcher
)
}
}
单元测试
我们配合SourceSet进行单元测试,将fake的测试类定义在SourceSet目录中,
@Module
@InstallIn(ApplicationComponent::class)
abstract class TestTasksRepositoryModule {
@Singleton
@Binds
abstract fun bindRepository(repo: FakeRepository): TasksRepository
}
然后在实际代码中,通过@UninstallModules
卸载业务对象,替换为测试对象
@RunWith(AndroidJUnit4::class)
@MediumTest
@ExperimentalCoroutinesApi
// ** ↓UninstallModules将默认的业务对象删除,加载TestTasksRepositoryModule测试对象 **
@UninstallModules(TasksRepositoryModule::class)
@HiltAndroidTest
class TasksFragmentTest {
ActivityComponent
@AndroidEntryPoint
可以将Activity关联到ActivityComponent
@AndroidEntryPoint
class TasksActivity : AppCompatActivity() {
Activity的中使用Navigation显示Fragment
//TasksActivity.kt
val navController: NavController = findNavController(R.id.nav_host_fragment)
appBarConfiguration =
AppBarConfiguration.Builder(R.id.tasks_fragment_dest, R.id.statistics_fragment_dest)
.setDrawerLayout(drawerLayout)
.build()
setupActionBarWithNavController(navController, appBarConfiguration)
findViewById<NavigationView>(R.id.nav_view)
.setupWithNavController(navController)
<!--tasks_act.xml-->
<fragment
android:id="@+id/nav_host_fragment"
android:name="androidx.navigation.fragment.NavHostFragment"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:defaultNavHost="true"
app:navGraph="@navigation/nav_graph" />
<!--nav_graph.xml-->
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/nav_graph"
app:startDestination="@id/tasks_fragment_dest">
<!-- ↑ 设置初始Fragment ↑ -->
<fragment
android:id="@+id/task_detail_fragment_dest"
android:name="com.example.android.architecture.blueprints.todoapp.taskdetail.TaskDetailFragment"
android:label="Task Details">
...
<argument
android:name="taskId"
app:argType="string" />
...
</fragment>
<!-- **↓ 定义初始Fragment ↓** -->
<fragment
android:id="@+id/tasks_fragment_dest"
android:name="com.example.android.architecture.blueprints.todoapp.tasks.TasksFragment"
android:label="@string/app_name">
...
<action
android:id="@+id/action_tasksFragment_to_taskDetailFragment"
app:destination="@id/task_detail_fragment_dest" />
...
</fragment>
FragmentComponent
@AndroidEntryPoint关联TasksFragment和FragmentComponent
@AndroidEntryPoint
class TasksFragment : Fragment() {
private val viewModel by viewModels<TasksViewModel>()
private val args: TasksFragmentArgs by navArgs()
TasksFragment的参数通过navigation的navArgs
获取,显示任务列表,如下图:
单元测试
SourceSet中定义测试对象:
@Module
@InstallIn(ApplicationComponent::class)
abstract class TestTasksRepositoryModule {
@Singleton
@Binds
abstract fun bindRepository(repo: FakeRepository): TasksRepository
}
通过HiltAndroidRule
,注入测试对象进行测试:
@RunWith(AndroidJUnit4::class)
@MediumTest
@ExperimentalCoroutinesApi
// ** ①卸载业务对象 **
@UninstallModules(TasksRepositoryModule::class)
@HiltAndroidTest
class TasksFragmentTest {
@get:Rule
var hiltRule = HiltAndroidRule(this)
// ② 根据HiltAndroidRule、注入FakeRepository
@Inject
lateinit var repository: TasksRepository
@Before
fun init() {
// Populate @Inject fields in test class
hiltRule.inject()
}
使用FakeRepository保存数据,并在TestCase中使用
@Test
fun displayTask_whenRepositoryHasData() {
// GIVEN - One task already in the repository
repository.saveTaskBlocking(Task("TITLE1", "DESCRIPTION1"))
// WHEN - On startup
launchActivity()
// THEN - Verify task is displayed on screen
onView(withText("TITLE1")).check(matches(isDisplayed()))
}
private fun launchActivity(): ActivityScenario<TasksActivity>? {
val activityScenario = launch(TasksActivity::class.java)
activityScenario.onActivity {
activity ->
// Disable animations in RecyclerView
(activity.findViewById(R.id.tasks_list) as RecyclerView).itemAnimator = null
}
return activityScenario
}
这里使用launchFragmentInHiltContainer
启动Fragment
@Test
fun clickAddTaskButton_navigateToAddEditFragment() {
// GIVEN - On the home screen
val navController = mock(NavController::class.java)
launchFragmentInHiltContainer<TasksFragment>(Bundle(), R.style.AppTheme) {
Navigation.setViewNavController(this.view!!, navController)
}
// WHEN - Click on the "+" button
onView(withId(R.id.add_task_fab)).perform(click())
// THEN - Verify that we navigate to the add screen
verify(navController).navigate(
TasksFragmentDirections.actionTasksFragmentToAddEditTaskFragment(
null, getApplicationContext<Context>().getString(R.string.add_task)
)
)
}
Fragment的启动依赖Activity,构造HiltTestActivity
帮助Fragment启动
/**
* 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(
fragmentArgs: Bundle? = null,
@StyleRes themeResId: Int = R.style.FragmentScenarioEmptyFragmentActivityTheme,
crossinline action: Fragment.() -> Unit = {
}
) {
val startActivityIntent = Intent.makeMainActivity(
ComponentName(
ApplicationProvider.getApplicationContext(),
HiltTestActivity::class.java
)
).putExtra(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()
}
}