ОБНОВЛЕНИЕ:
Некоторые части этой статьи устарели. Теперь есть функции расширения делегата Kotlin, которые значительно упрощают часть этого процесса. Я не буду здесь вдаваться в подробности, но приведу короткие примеры их использования.
Добавлять ViewModels к действиям теперь можно следующим образом.
class MainActivity : Activity() { @Inject lateinit var factory: ViewModelProvider.Factory private val mainViewModel: MainViewModel by viewModels { factory } override fun onCreate(savedInstanceState: Bundle?) { AndroidInjection.inject(this) super.onCreate(savedInstanceState) } }
Точно так же предоставление ViewModels фрагментам теперь можно выполнить следующим образом.
class MainFragment : Fragment() { @Inject lateinit var factory: ViewModelProvider.Factory private val mainViewModel: MainViewModel by activityViewModels { factory } override fun onAttach(context: Context) { AndroidSupportInjection.inject(this) super.onAttach(context) }
Это не только очищает ваш код, но и устраняет необходимость в ViewModelClassMap, используемом ниже.
Вы можете прочитать оригинальную статью ниже.
Я недавно начал работать с компонентами архитектуры Android, и они потрясающие. Несмотря на опоздание на вечеринку (эти компоненты существуют уже некоторое время), мне было трудно найти документацию для того, что я считаю обычным вариантом использования: реализация моделей общего представления с инъекцией Dagger, которая могла бы легко поддерживать тестирование.
Сама ViewModel довольно хорошо документирована и проста в реализации. Если вы новичок в Android ViewModels, я предлагаю документацию Android как способ изучить основы.
После реализации моей первой модели общего представления я начал понимать мощь таких компонентов, как ViewModel и LiveData. Однако, выходя за рамки базового варианта использования ViewModels, я столкнулся с трудностями. Природа компонента с учетом жизненного цикла, совместно используемого несколькими действиями / фрагментами, может в определенном смысле усложнить жизнь. Я хотел бы использовать эту статью, чтобы рассказать вам о некоторых болевых точках, которые у меня были, и о том, как мне удалось их решить.
Первой серьезной трудностью, которую я заметил, было внедрение зависимостей с помощью Dagger. К счастью, мы можем прочитать эту отличную статью от Chili Labs, чтобы начать работу с ViewModel DI.
Я настоятельно рекомендую статью Chili Labs, если у вас возникли трудности с внедрением зависимостей ViewModel с помощью Dagger. Остальная часть этой статьи основана на подходе, принятом Chili Labs, поэтому перед тем, как двигаться дальше, настоятельно рекомендуется понять этот подход.
Второе препятствие, с которым мы сталкиваемся, - это сокрытие реализации ViewModel за абстракцией. Обычно это делается путем создания интерфейса и его расширения, а затем привязки интерфейса к реализации в Dagger, чтобы он знал, какую реализацию предоставить при запросе интерфейса. Примерно так.
interface MyUseCase { fun getInt(): Int } class MyUseCaseImpl : MyUseCase { override fun getInt() = 20 } @Module abstract class UseCaseBindingsModule { @Binds abstract fun myUseCase(impl: MyUseCaseImpl): MyUseCase }
Это дает нам возможность легко заменить реализацию на заглушку, чтобы мы могли тестировать пользовательский интерфейс отдельно от логики UseCase.
К сожалению, из-за того, как мы внедряем ViewModels, мы не можем использовать описанный выше подход. Наша инъекция настроена на использование имени класса ViewModel, чтобы определить, какой поставщик использовать в ViewModelProvider.Factory, а интерфейсы не имеют имени класса. Итак, мы должны использовать абстрактный класс вместо интерфейса.
abstract class MainViewModel: ViewModel() { abstract val rand: LiveData<Int> } class MainViewModelImpl : MainViewModel() { override val rand = MutableLiveData().apply { value = Random().nextInt() } }
Внеся несколько небольших изменений в настройку Chili Labs, мы можем предоставить нашу реализацию всякий раз, когда абстракция запрашивается @Inject.
// Slightly altered code from the Chili Labs example to support abstract ViewModels @Provides @Singleton fun viewModelFactory( providers: Map<Class<out ViewModel>, @JvmSuppressWildcards Provider<ViewModel>> ) = object : ViewModelProvider.Factory { override fun <T : ViewModel> create(modelClass: Class<T>): T { val provider = providers[modelClass] ?: providers .asIterable() .firstOrNull { it.key.isAssignableFrom(modelClass) } ?.value return requireNotNull(provider).get() as T } } @Module(includes = [ ViewModelModule.ProvideViewModel::class, ViewModelModule.ProvideViewModelAbstractionMap::class ]) abstract class ViewModelModule { @Module class InjectViewModel { // Define the return type to be the abstraction, but ViewModelProviders 'gets' the implementation @Provides fun mainViewModel(): MainViewModel = ViewModelProviders.of(target, factory) .get(MainViewModelImpl::class.java) } // Used so fragments can get the ViewModel from their activity without knowing what the implementation is @Module class ProvideViewModelAbstractionMap { @Provides fun viewModelClassMap(): ViewModelClassMap = mapOf( MainViewModel::class.java to MainViewModelImpl::class.java ) } } @Module class ProvideViewModel { @Provides @IntoMap @ViewModelKey(MainViewModel::class) // another small alteration, using the abstract class as the ViewModelKey and return type fun mainViewModel(): MainViewModel = MainViewModelImpl() }
Обратите внимание на использование следующих псевдонимов
typealias ViewModelClassMap = Map<Class<out ViewModel>, @JvmSuppressWildcards Class<out ViewModel>> // This extension is used next @Suppress("UNCHECKED_CAST") inline fun <reified T: ViewModel> ViewModelClassMap.getImplClass(clazz: Class<out ViewModel>): Class<T> = requireNotNull(get(clazz)) as Class<T>
Как и в случае с примером Chili Labs, наша деятельность может напрямую внедрять ViewModel, как и раньше, но теперь не зная, какую реализацию она использует.
class MainActivity : Activity() { private lateinit var mainViewModel: MainViewModel @Inject fun inject(mainViewModel: MainViewModel) { this.mainViewModel = mainViewModel } override fun onCreate(savedInstanceState: Bundle?) { AndroidInjection.inject(this) super.onCreate(savedInstanceState) } }
Нашим фрагментам нужно всего лишь несколько изменений, чтобы они могли внедрить ViewModel, не зная о реализации.
class MainFragment : Fragment() { private lateinit var classMap: ViewModelClassMap private lateinit var mainViewModel: MainViewModel @Inject fun inject(classMap: ViewModelClassMap) { this.classMap = classMap } override fun onAttach(context: Context) { AndroidSupportInjection.inject(this) super.onAttach(context) } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) viewModel = ViewModelProviders .of(requireActivity()) .get(classMap.getImplClass<MainViewModel>(MainViewModel::class.java)) } }
Теперь мы полностью работаем в абстракциях, разделяя один экземпляр модели ViewModel с учетом жизненного цикла для действия и фрагмента. Можно легко добавить больше фрагментов, которые также будут использовать один и тот же экземпляр ViewModel.
Последнее препятствие возникает, когда мы начинаем писать тесты для этой деятельности. Используя ViewModelClassMap, мы можем достаточно легко заменить нашу реализацию ViewModel заглушкой. Затем проблема заключается в том, чтобы использовать один и тот же экземпляр этой заглушки для действия и фрагментов. Для этого нам просто нужно использовать часть головоломки, которую мы уже разместили.
@Provides @Singleton fun viewModelFactory( providers: Map<Class<out ViewModel>, @JvmSuppressWildcards Provider<ViewModel>> ) = object : ViewModelProvider.Factory { override fun <T : ViewModel> create(modelClass: Class<T>): T { val provider = providers[modelClass] ?: providers .asIterable() .firstOrNull { it.key.isAssignableFrom(modelClass) } ?.value return requireNotNull(provider).get() as T } }
В настоящее время наш фрагмент не использует ViewModelProvider.Factory, который Dagger настроен предоставлять нам, потому что в этом нет необходимости. Если мы запустим наше приложение, мы заметим, что фрагмент использует тот же экземпляр ViewModel, что и действие. Но когда мы запускаем тест, мы видим, что активность и фрагмент используют разные экземпляры модели представления. Это связано с тем, что мы предоставляем активность непосредственно экземпляру в нашем тесте, но фрагмент использует ViewModelProviders для получения экземпляра.
Мы можем предотвратить появление у фрагмента другого экземпляра, используя фабрику, которую мы создали для Dagger.
class MainFragment : Fragment() { private lateinit var classMap: ViewModelClassMap private lateinit var factory: ViewModelProvider.Factory private lateinit var mainViewModel: MainViewModel @Inject fun inject( classMap: ViewModelClassMap, factory: ViewModelProvider.Factory ) { this.classMap = classMap this.factory = factory } override fun onAttach(context: Context) { AndroidSupportInjection.inject(this) super.onAttach(context) } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) mainViewModel = ViewModelProviders .of(requireActivity(), factory) .get(classMap.getImplClass<MainViewModel>(MainViewModel::class.java)) }
При тестировании мы создаем фабрику-заглушку, которая предоставляет тот же экземпляр ViewModel, который мы «вводим» в действие.
class MainViewModelStub : MainViewModel() { override val rand = MutableLiveData<Int>().apply { value = Random().nextInt() } } class MainActivityTests { private val targetContext = ApplicationProvider.getApplicationContext<Context>() @Rule @JvmField val testActivityRule = ActivityTestRule(MainActivity::class.java, true, false) @Rule @JvmField var androidInjectionRule = AndroidInjectionRule() private val classMapStub: ViewModelClassMap = mapOf(MainViewModel::class.java to MainViewModelStub::class.java) private val viewModelStub = MainViewModelStub() private val factoryStub = object : ViewModelProvider.Factory { override fun <T : ViewModel?> create(modelClass: Class<T>): T = viewModelStub as T } @Before fun setUp() { androidInjectionRule.fragmentInjector = AndroidInjector { fragment -> when (fragment) { is MainFragment -> { fragment.inject(classMapStub, factoryStub) } } } androidInjectionRule.activityInjector = AndroidInjector { activity -> when (activity) { is MainActivity -> activity.inject(viewModelStub) } } } @Test fun test_that_it_works() { val activity = MainActivity.newIntent(targetContext) testActivityRule.launchActivity(activity) }
Теперь активность и фрагмент (ы) используют один и тот же экземпляр MainViewModel, и мы можем начать тестирование нашего пользовательского интерфейса!
Полный пример проекта можно найти здесь.
Если у вас есть предложения по улучшению, оставьте комментарий ниже и / или создайте запрос на перенос!