Android
September 3, 2021

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-слое, ведь задача просто успешно сделать сетевые запросы через разные инструменты и увидеть информацию в логе.

Меньше слов и больше дела! Поехали!

Акт нулевой: затаскиваем зависимости в проект

Студия сгенерировала пустой проект. Начинаем добавлять все нужные зависимости.

1. В build.gradle проекта:

...
dependencies {    
...   
classpath 'com.google.dagger:hilt-android-gradle-plugin:2.38.1'
...
}
...

2. В build.gradle модуля:

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