В данной статье хочется показать подробный пример использования Hilt совместно с Retrofit, запросы к бд будут ассинхронно выполнять с помощью Coroutines.
Hilt - это библиотека для внедрения зависимостей для Android, которая сокращает количество шаблонов для ручного внедрения зависимостей в ваш проект. https://developer.android.com/training/dependency-injection/hilt-android
Retrofit - типобезопасный HTTP-клиент для Android и Java. Позволяет нам не особо напрягясь делать GET, POST, PUT и другие запросы.
Coroutines - шаблон проектирования параллелизма, который можно использовать в Android для упрощения кода, который выполняется асинхронно.
Подключение зависимостей
В top-level build file, то есть build.gradle который находиться в самой папке нашего проекта, а не в app, добавляем следующее:
buildscript {
...
ext.hilt_version = '2.35'
dependencies {
...
classpath "com.google.dagger:hilt-android-gradle-plugin:$hilt_version"
}
}
Далее, уже в build.gradle, который находится в папочке app, Navigation подключаем для удобства ориентации https://developer.android.com/guide/navigation/navigation-getting-started:
plugins {
id("kotlin-kapt")
id("dagger.hilt.android.plugin")
}
android {
...
}
dependencies {
//Hilt
implementation("com.google.dagger:hilt-android:$hilt_version")
kapt("com.google.dagger:hilt-android-compiler:$hilt_version")
//Retrofit
implementation 'com.squareup.retrofit2:retrofit:2.9.0'
implementation 'com.squareup.retrofit2:converter-gson:2.9.0'
implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.2.0'
//Navigation
implementation("androidx.navigation:navigation-fragment-ktx:2.3.5")
implementation("androidx.navigation:navigation-ui-ktx:2.3.5")
}
Теперь расставим аннотации
В соответствии с документацией : "Все приложения, использующие Hilt, должны содержать класс Application, помеченный @HiltAndroidApp ".
Поэтому создаём класс MyApplication, который наследуем от Application:
@HiltAndroidApp
class MyApplication : Application() {
}
Также не забываем включить этот класс в AndroidManifest, чтобы при сборке всего приложения использовался именно наш класс:
android:name=".MyApplication"
Теперь добавляем аннотацию @AndroidEntryPoint к MainFragment и MainActivity. Эта аннотацию говорит Hilt, чтобы он генерировал классы Component https://developer.android.com/training/dependency-injection/hilt-android#generated-components . Каждый компонент отвечает за зависимости своего класса, ActivityComponent за Activity, FragmentComponent за Fragment. Именно эта аннотация позволяет нам в дальнейшеи привязывать зависимости к Fragment, Activity и т.д.. Также если вы указали эту аннотацию, например, у какого-то фрагмента, её также необходимо указать у Activity, к которой он привязан. Дополнительно про иерархию можно посмотреть по ссылке.
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
}
}
@AndroidEntryPoint
class MainFragment : Fragment() {
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
// Inflate the layout for this fragment
return inflater.inflate(R.layout.fragment_main, container, false)
}
}
Далее, в соответствии с архитектурой MVVM нам нужно создать ViewModel, которая будет содержать наши LiveData, данные в которые мы будем загружать из MainRepository:
@Singleton
class MainRepository @Inject constructor() {
...
}
Аннотация @Singleton говорит Hilt, что наш MainRepository будет привязан к SIngletonComponent, то есть к Application. Первый раз, когда где-то будет создаваться MainRepository, он создаст сам экземпляр класса, в последующие разы будет доставаться тот же самый, созданный в 1 раз MainRepository. Но чтобы Hilt знал, как создавать репозиторий, нужно использовать вот такой синтаксис : @Inject constructor(....). Если мы далее захотим где-то в коде получить MainRepository, то сделать это можно так: @Inject lateinit var repository: MainRepository.
Теперь объявим ViewModel:
@HiltViewModel
class MainViewModel @Inject constructor(val repository: MainRepository) : ViewModel(){
...
}
В Hilt для ViewModel есть специальная аннотация @HiltViewModel , ViewModel также как и раньше будут привязаны к жизненным циклам Fragment или Activity, смотря где вы захотите её создать. Также необходимо указать @Inject, по причинам описанным выше, а тк Hilt уже знает, как создавать repository, всё будет отлично работать(при указании @Inject , Hilt должен знать, как создавать объекты, которые находятся в constructor). Теперь во фрагменте мы можем следующим образом получить нашу ViewModel:
private val viewModel by viewModels<MainViewModel>()
Возникает резонный вопрос, зачем все эти заморочки с Hilt, если мы и до этого могли получить ViewModel с помощью by viewModels. Всё дело в том, что так можно сделать, если у нашей ViewModel пустой конструктор, что в случае с MainViewModel не так. Поэтому пришлось бы написать гораздо больше шаблонного кода, использование которого мы и хотим избежать.
Retrofit
При использовании Retrofit также необходимо разрешение на использование интернета, для получения данных удалённой базы данных(в данном примере используется Firebase), поэтому перед тегом <application в AndroidManifest.xml необходимо добавить следующую строчку:
<uses-permission android:name="android.permission.INTERNET"/>
Cоздадим data class, в котором будут храниться данные о пользователе:
data class User(
val age : Int,
val name : String
)
Теперь создаём interface, нужный для использования Retrofit, где пропишем все нужные нам запросы:
interface MainService {
@GET("User/{userId}.json")
suspend fun getUser(@Path("userId") userId : Int) : Response<User>
}
Пока у нас 1 запрос GET. Аннотация @Path означает, что значение userId будет подставляться в сегмент URL {userId}.
class MainRemoteData @Inject constructor(private val mainService : MainService) {
suspend fun getUser(userId : Int) = mainService.getUser(userId)
}
В дальнейшем тут можно добавить логику обработки ответа: кода ответа, сообщения об ошибке и т.д..
Далее, поскольку у интерфейса нет конструктора, а значит Hilt не может знать как его реализовать. Также Retrofit сторонняя библиотеке, значит о ней Hilt тоже ничего не знает, как же тогда поступить, чтобы Hilt знал как предоставлять экземпляры того или иного класса? На помощь нам придёт Module, он представляет собой набор методов, которые указывают как создать экземпляр нужного нам класса:
@Module
@InstallIn(SingletonComponent::class)
object AppModule {
@Provides
fun providesBaseUrl() : String = "https://example-hilt-retrofit-default-rtdb.firebaseio.com/"
@Provides
@Singleton
fun provideRetrofit(BASE_URL : String) : Retrofit = Retrofit.Builder()
.addConverterFactory(GsonConverterFactory.create())
.baseUrl(BASE_URL)
.build()
@Provides
@Singleton
fun provideMainService(retrofit : Retrofit) : MainService = retrofit.create(MainService::class.java)
@Provides
@Singleton
fun provideMainRemoteData(mainService : MainService) : MainRemoteData = MainRemoteData(mainService)
}
Аннотация @InstallIn говорит в контексте чего будут доступны наши зависимости. В данном случае мы хотим получать их в любом месте приложения и поэтому указываем SingletonComponent. Далее мы указываем аннотацию @Provides, чтобы Hilt знал как предоставлять экземпляры типов Retrofit и т.д.. Аннотация @Singleton , как было сказано ранее, говорит Hilt, чтобы он не пересоздавал каждый раз новый экземпляр, а предоставлял уже имеющийся(если ни разу ещё не был создан, то он его создаст).
Заметим, что мы также говорим, как предоставлять экземпляр String, это значит, что если мы где-то создадим класс по типу такого:
@Singleton
class Test @Inject constructor(private val str : String) {
fun print() {
println(str)
}
}
То при его получении с помощью:
@Inject lateinit var test : Test
И вызове метода print(), будет печататься именно BASE_URL.
Передаём данные во ViewModel
Теперь по сути основная работа выполнена, осталось поместить наши данные во ViewModel, а потом из фрагмента достать LiveData и следить за изменением данных в ней.
Прокидываем данные через репозиторий:
@Singleton
class MainRepository @Inject constructor(
private val remoteData : MainRemoteData
) {
suspend fun getUser(userId : Int) = remoteData.getUser(userId)
}
Потом превращаем их в LiveData во ViewModel:
@HiltViewModel
class MainViewModel @Inject constructor(val repository: MainRepository) : ViewModel(){
fun getUser(userId : Int) : LiveData<User> {
return liveData {
val data = repository.getUser(userId)
data.body()?.let { emit(it) }
}
}
}
Вызов emit () приостанавливает выполнение блока до тех пор, пока значение LiveData не будет установлено в основном потоке. То есть пока наша suspend fun не выполниться.
UI
Теперь наконец отобразим данные, которые мы получаем из Firebase. Для этого в MainFragment добавим 2 Textview:
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
xmlns:app="http://schemas.android.com/apk/res-auto"
tools:context=".MainFragment">
<!-- TODO: Update blank fragment layout -->
<TextView
android:id="@+id/name_field"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="name"
android:textSize="50sp"
android:layout_marginTop="24dp"
android:layout_marginLeft="24dp"
android:layout_marginRight="24dp"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent" />
<TextView
android:id="@+id/age_field"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:textSize="50sp"
android:text="age"
android:layout_marginTop="24dp"
android:layout_marginLeft="24dp"
android:layout_marginRight="24dp"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toBottomOf="@+id/name_field" />
</androidx.constraintlayout.widget.ConstraintLayout>
И в MainFragment будем устанавливать значения, которые мы получили из ViewModel:
@AndroidEntryPoint
class MainFragment : Fragment() {
private val viewModel by viewModels<MainViewModel>()
@SuppressLint("SetTextI18n")
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View {
val constraintLayout = inflater.inflate(R.layout.fragment_main, container, false)
as ConstraintLayout
val name : TextView = constraintLayout.findViewById(R.id.name_field)
val age : TextView = constraintLayout.findViewById(R.id.age_field)
viewModel.getUser(0).observe(viewLifecycleOwner, {user ->
name.text = "name : " + user.name
age.text = "age : " + user.age.toString()
})
return constraintLayout
}
}
Результат(конечно не очень красивый, но мы к этому и не стремились):

Вот и всё, простой пример использования Hilt+retrofit готов! Полный код доступен по ссылке.