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 остался больше по душе.