Android-NoobLabs: RxJava2 и Kotlin Coroutines
И зачем это все надо?
Это заметка для зеленых новичков. Не самые зеленые и не самые новички - давно уже попробовали руками все сами. В данной заметке я буду использовать: Dagger Hilt, RxJava2, Kotlin Coroutines и Retrofit2. Dagger Hilt задействован исключительно для МОЕГО удобства, строго не рекомендую начинать познавать Dagger с Hilta, потому что сокращена куча boiler-plate'а, которая дает понимание принципов работы Dagger'а, поэтому это лишь помощь мне, чтоб не плодить совсем дикий щиткод в примерах, за который вам отобьют руки всей бригадой за который отклонят в будущем ваши pull-request'ы.
Y-нылое введение
RxJava - это реализация библиотеки ReactiveX с открытым исходным кодом, которая помогает создавать приложения в стиле реактивного программирования.
И изначально основное предназначение было именно в этом, а не в том, что в этом вашем Android'е вы ей только потоки переключаете, негодники и негодницы господа и дамы.
С Kotlin Coroutines - все сложнее. Чтобы в них разобраться - надо английский знать, поэтому обращаемся к русскоязычной документации:
Сопрограммы - это легковесные потоки. Сопрограммы обеспечивают возможность избежать блокировки исполняющегося потока путём использования более дешёвой и управляемой операции: приостановки (suspend) сопрограммы.
И тяжелее всего осознать, как практически обеспечивается эта самая легковесность. Что есть еще на эту тему в доке:
Что делаю и как будет выглядеть?
В качестве полигона буду делать приложение с единственной Activity, что имеет некую ViewModel, которая содержит DataSource-класс (не хотелось обзывать Repository). DataSource что является источником данных и представляем, что этот класс будет нужен на уровне всего приложения. В качестве источника данных буду использовать сторонее API из списка публичных API. И буду поэтапно дергать сетевые запросы: сначала через RxJava2, потом через Coroutines, причем даже не буду ничего делать в UI-слое, ведь задача просто успешно сделать сетевые запросы через разные инструменты и увидеть информацию в логе.
Меньше слов и больше дела! Поехали!
Акт нулевой: затаскиваем зависимости в проект
Студия сгенерировала пустой проект. Начинаем добавлять все нужные зависимости.
...
dependencies {
...
classpath 'com.google.dagger:hilt-android-gradle-plugin:2.38.1'
...
}
...
plugins {
...
id 'kotlin-kapt'
id 'dagger.hilt.android.plugin'
}
dependencies {
...
implementation 'androidx.fragment:fragment-ktx:1.3.6' // for by viewModels()
//Rx Java2
implementation 'io.reactivex.rxjava2:rxandroid:2.1.1'
implementation 'io.reactivex.rxjava2:rxjava:2.2.9'
//Coroutines
def coroutines = "1.5.1-native-mt"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutines"
//retrofit 2
def retrofit2 = "2.9.0"
implementation "com.squareup.retrofit2:retrofit:$retrofit2"
implementation "com.squareup.retrofit2:converter-gson:$retrofit2"
implementation "com.squareup.retrofit2:adapter-rxjava2:2.4.0"
//Dagger Hilt
def daggerHilt = "2.38"
implementation "com.google.dagger:hilt-android:$daggerHilt"
kapt "com.google.dagger:hilt-compiler:$daggerHilt"
}
Акт первый: закладываем основы основ
Создаем сущность Data-класса, что будет возвращаться по запросу:
data class Fact( val fact: String )
Создаем API-интерфейс Retrofit'а для RxJava2:
interface ApiRx {
@GET("resources/dogs/all")
fun getAllFactsRx() : Single<List<Fact>>
companion object {
private const val BASE_URL
= "https://dog-facts-api.herokuapp.com/api/v1/"
private var gson = GsonBuilder()
.setLenient()
.create()
var retrofit: Retrofit = Retrofit.Builder()
.baseUrl(BASE_URL)
.addConverterFactory(GsonConverterFactory.create(gson))
.addCallAdapterFactory(RxJava2CallAdapterFactory.create())
.build()
}
}Создаем API-интерфейс Retrofit'а для Kotlin Coroutines:
interface ApiCoroutines {
@GET("resources/dogs/all")
suspend fun getAllFactsCoroutines() : List<Fact>
companion object {
private const val BASE_URL
= "https://dog-facts-api.herokuapp.com/api/v1/"
private var gson = GsonBuilder()
.setLenient()
.create()
var retrofit: Retrofit = Retrofit.Builder()
.baseUrl(BASE_URL)
.addConverterFactory(GsonConverterFactory.create(gson))
.build()
}
}Акт второй: основы для Dagger'а
В первую очередь создаем класс App, помечаем аннотацией, добавляем Permission для Internet'а и регистрируем в manifest'е:
@HiltAndroidAppclass App : Application()
... <uses-permission android:name="android.permission.INTERNET"/> <application ... android:name=".App" </application> ...
Акт третий: создаем класс DataSource, ViewModel и дописываем коллбэк в Activity
class DataSource(
private val apiRx: ApiRx,
private val apiCoroutines: ApiCoroutines
) {
}@HiltViewModel
class MainViewModel @Inject constructor(
private val dataSource: DataSource
) : ViewModel() {
fun onCreate() {
}
private fun fetchDataRx() {
}
private fun fetchDataCoroutines() {
}
}@AndroidEntryPointclass
MainActivity : AppCompatActivity() {
private val viewModel: MainViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
viewModel.onCreate()
}
}Акт четвертый: создаем компонент приложения
@Module
@InstallIn(SingletonComponent::class)
class AppComponent {
@Provides
fun providesApiCoroutines()
= ApiCoroutines.retrofit.create(ApiCoroutines::class.java)
@Provides
fun providesApiRx()
= ApiRx.retrofit.create(ApiRx::class.java)
@Provides
@Singleton
fun providesDataSource(apiRx: ApiRx, apiCoroutines: ApiCoroutines)
= DataSource(apiRx, apiCoroutines)
}Акт пятый: дергаем API через RXJava2
Начинаем с DataSource, добавляем туда функцию:
fun getFactsRx() : Single<List<Fact>> {
return apiRx.getAllFactsRx()
}Переходим к MainViewModel, где нужно выполнить подписку на событие:
...
fun onCreate() {
fetchDataRx()
}
@SuppressLint("CheckResult")
private fun fetchDataRx() {
dataSource.getFactsRx()
.subscribe({
Log.d("MainViewModelDebug", "Success: $it")
}, {
Log.d("MainViewModelDebug", "Error: $it")
})
}
...Суровые синьоры со стажем догадались, что вывелось в консоль!
MainViewModelDebug: Error: android.os.NetworkOnMainThreadException
Акт шестой: разбираемся, что пошло не так
Цитирую отличную выжимку статьи с сайта xakep.ru, которая всё объясняет:
Запущенное в Android приложение имеет собственный процесс и как минимум один поток — так называемый главный поток (main thread). Если в приложении есть какие-либо визуальные элементы, то в этом потоке запускается объект класса Activity, отвечающий за отрисовку на дисплее интерфейса (user interface, UI).
В главном Activity должно быть как можно меньше вычислений, единственная его задача — отображать UI. Если главный поток будет занят подсчетом числа пи, то он потеряет связь с пользователем — пока число не досчиталось, Activity не сможет обрабатывать запросы пользователя и со стороны будет казаться, что приложение зависло. Если ожидание продлится чуть больше пары секунд, ОС Android это заметит и пользователь увидит сообщение ANR (application not responding — «приложение не отвечает») с предложением принудительно завершить приложение.
Акт седьмой: дергаем API через RXJava, но теперь корректно
Добавляем важные строки, которых не хватало. Фактически эти две строки можно интерпретировать так: оператор создания subscribeOn приказывает выполниться методу getAllFactsRx() в отдельном IO-потоке, а оператор observeOn передает результат выполнения в Main-thread Android'а:
fun getFactsRx() : Single<List<Fact>> {
return apiRx.getAllFactsRx()
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
}Смотрим лог, и там пришел ответ сервера, значит всё вандефульно:
MainViewModelDebug: Success: [Fact(fact=All dogs can be traced ...
Акт восьмой: дергаем API через Coroutines
Идем в DataSource и аналогично Rx добавляем функцию:
suspend fun getFactsCoroutines() : List<Fact> {
return apiCoroutines.getAllFactsCoroutines()
}И вызываем метод из ViewModel:
...
fun onCreate() {
//fetchDataRx()
fetchDataCoroutines()
}
private fun fetchDataCoroutines() {
val exceptionHandler = CoroutineExceptionHandler { _, it ->
Log.d("MainViewModelDebug", "Error: $it")
}
viewModelScope.launch(exceptionHandler) {
val resultGetFacts = dataSource.getFactsCoroutines()
Log.d("MainViewModelDebug", "Success: $resultGetFacts")
}
}
...MainViewModelDebug: Success: [Fact(fact=All dogs can be traced...
И здесь нужно остановиться и подумать. Что упущено относительно RxJava2?
private fun fetchDataCoroutines() {
...
viewModelScope.launch(exceptionHandler) {
...
Log.d("MainViewModelDebug", "Current thread: ${Thread.currentThread()};\nisMainThread: ${Looper.getMainLooper().thread == Thread.currentThread()}")
}
}И после этого видим в консоли:
MainViewModelDebug: Current thread: Thread[main,5,main]; isMainThread: true
И вот она та самая легковесность, о какой сообщалось в самом начале заметки в описании корутин. Не смотря на то, что мы явно не давали указание о том, что запрос делать нужно не в Main-потоке, корутины с помощью подкапотной магии не вывалились в Exception.
Акт девятый: дергаем API через Coroutines, но теперь корректно
Не смотря на то, что Exception не произошел - работать с корутинами нужно правильно. И, как мы уже знаем, нужно сделать так, чтобы корутина поняла, что мы хотим выполнить операцию не через главный поток. Для удобства сделаем это, как и в случае с RxJava2, на уровне DataSource и посмотрим на результат.
Вносим изменения в DataSource в getFactsCoroutines():
suspend fun getFactsCoroutines() : List<Fact> {
return withContext(Dispatchers.IO) {
Log.d("MainViewModelDebug", "DataSource: Current thread: ${Thread.currentThread()};\nisMainThread: ${Looper.getMainLooper().thread == Thread.currentThread()}")
apiCoroutines.getAllFactsCoroutines()
}
}MainViewModelDebug: DataSource: Current thread: Thread[DefaultDispatcher-worker-1,5,main]; isMainThread: false MainViewModelDebug: Success: [Fact(fact=All dogs can be traced... MainViewModelDebug: Current thread: Thread[main,5,main]; isMainThread: true
Результат говорит о том, что на уровне DataSource операция выполнилась не в главном потоке, что нам как раз и нужно, а на уровне ViewModel'и мы уже оказываемся в Main-потоке.
Выводы
Что RxJava2 и что Kotlin Coroutines - две мощных библиотеки, позволяющие выполнять асинхронные операции, но с совершенно разными подходами по стилю написания. Оба инструмента помогают Android-разработчикам не грузить жизненноважный Main-thread, что занят и без того нелегкой отрисовкой UI. Kotlin Coroutines все больше набирает обороты и завоевывает новых сторонников из числа разработчиков в свои ряды, тем более на ее стороне легковесность, что позволила в данном примере не получить ошибку при выполнении сетевого запроса, как было в случае с RxJava2.
При всем при этом нельзя сказать, что век RxJava подошел к концу из-за атаки Корутин и завоевание почти 100% рынка проектов не за горами. У данной библиотеки останется немалое количество поклонников, да и самое главное, что всем нам долго никуда не деться от легаси-кода. Кроме того, на Ютубе и в подкастах, но не помню где точно - слышал мнение, что люди поработали с корутинами, но подход RxJava остался больше по душе.