Идея написания данной статьи возникла у меня тогда, когда я начал изучать корутины, это был 2019 год, тогда я только вошёл в Андроид-разработку и меня интересовало всё что с этим связанно. В то время корутины только набирали обороты и про них начинали рассказывать на различных конференциях и митапах связанных с Андроид-разработкой, где я собственно говоря и узнал про них. На таких митапах рассказывали чем они хороши, в чем их преимущества от потоков и т.д.. Именно тогда я и заинтересовался ими, стал гуглить про корутины, читать статьи и смотреть видеоролики по корутинам и искать какие-нибудь тренинги в просторах интернета. Однако тогда, да и сейчас я практически не встречал ни одной статьи где показывают корутины на реальном примере и объясняют его простым языком, поэтому мне было трудно разобраться как применить их в реальной задаче, как например мы сможем корутинами заменить Handler, однако таких примеров в глобальной паутине под названием интернет мне найти не удалось. И тут у меня возникла в голове мысль, а вот была бы такая статья на хабаре, сколько бы я времени сэкономил на изучение корутин, посмотрел бы я как их применять в действии, понял бы, как используются они на практике и процесс их изучение был бы гораздо эффективнее и быстрее, да и я бы стал их быстрее применять в реальных проектах. И вот я написал такую статью и надеюсь тебе уважаемый читатель она будет полезна и сэкономит твоё драгоценное время на их изучение.

Каждый, даже начинающий, Android-разработчик знает, что основной поток(MainThread) приложения отвечает только за отрисовку экрана и рендеринг view’шек. Остальные операции такие как выгрузка данных с сервера, из файловой системы, базы данных и т.д. должны выполняться в отдельно потоке дабы не перегружать основной поток, ускорить работу приложения, избежать всякого рода крэшей и т.д.. Для этих целей существует множество способов такие как корутины, handler, AsyncTask, RX и т.д.. В данной статье мы не будем говорить про deprecated методы такие как например AsyncTask, а рассмотрим только 3: корутины, handler и RX.

Сразу хочу предупредить что в данной статье я не буду подробно описывать как работает каждый из этих методов и над «чистотой» кода, который я буду показывать в примерах, я тоже особо не заморачивался. Я просто хочу показать на примере одной задачи, с которой может столкнуться каждый разработчик при создании своего приложения, как будет выглядеть решение с использованием этих трёх методов.

Для демонстрации было разработано приложение, которое выгружает список почтовых адресов из списка контактов и выводит их на экран приложения. Ну что приступим.

Handler

Один из самых распространённых методов асинхронной работы в Android приложениях это использование такого механизма как Handler.

Handler - это механизм, который позволяет работать с очередью сообщений. Он привязан к конкретному потоку (thread) и работает с его очередью. В Android к потоку (thread) может быть привязана очередь сообщений. Мы можем помещать туда сообщения, а система будет за очередью следить и отправлять сообщения на обработку.

Рассмотрим реализацию данного механизма на примере:

Для начала объявим Handler в методе жизненного цикла активити onCreate() и переопределим там функцию handleMessage() и реализуем там обработчик сообщения который будет служить триггером того что список почтовых адресов получены из базы данных и мы можем ими заполнять view-элемент.

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.activity_main)

    val handler = Handler(object : Callback() {
        fun handleMessage(msg: Message?): Boolean {
            if (MSG_EMAILS_ARE_LOADED === msg.what) {
                emailView.setAdapter(ArrayAdapter<Any?>(applicationContext,
                        android.R.layout.simple_dropdown_item_1line, getEmails())))
            }
            return true
        }
    })
}

Затем нужно реализовать отдельную функцию, в которой будет создан поток, в котором мы будем получать список почтовых адресов из телефонной книги смартфона и записывать их в коллекцию, как только все почтовые адреса будут получены сигнал MSG_EMAILS_ARE_LOADED будет отправлен Handler’у UI потока, где и будет заполняться view-элемент информацией, полученной из контактов.

private fun loadEmailsFromContacts() {
    val loadContactsThread = Thread {
        loadEmailsFromContacts()
        handler.sendEmptyMessage(MSG_EMAILS_ARE_LOADED)
    }
    loadContactsThread.start()
}

Согласитесь, что код выглядит не очень читабельным загрузка почтовых адресов реализована в одном месте, а их отображение на экране приложения в другом, а когда такой подход реализован в большом проекте то трудно будет разработчику, который будет читать код (например, во время код ревью), сразу понять, что к чему.

RX

Давайте теперь решим данную задачу с другим не менее известным подходом как RX. Здесь уже не нужно будет что-то целенаправленно объявлять в методах жизненного цикла активити. Достаточно будет только вызвать функцию, в которой и будет реализована вся магия. Давайте реализуем данную задачу.

private fun loadEmailsFromContacts() {
    Observable.create { list: ObservableEmitter<Any?> ->
        loadEmails()
        list.onNext(getEmails())
        list.onComplete()
    }.subscribeOn(Schedulers.io())
        .observeOn(AndroidSchedulers.mainThread())
        .subscribe { v: Any? ->
            emailsFromContacts = ArrayList(v as List<String>?)
                       emailView.setAdapter<ArrayAdapter<String>>(ArrayAdapter<String>(getApplicationContext(),      
                       R.layout.simple_dropdown_item_1line, emailsFromContacts))
            }
}

Здесь код выглядит уже более компактнее. Загрузка и отображение списка почтовых адресов уже находиться в одном месте. Асинхронный код выглядит как последовательный хотя загрузка почтовых адресов и отображения их на экране выполняются в разных потоках.

Coroutines

Чтобы писать код использованием реактивного программирования нужно понимать зачем нужны такие функции как create{}, map, just и т.д., что реализовывать внутри них, что нужно указать в subscribeOn(), а что в observeOn(), зачем нужен Scheduler, какие у него бывают разновидности и что должно быть реализовано в subscribe{}. Ну и в целом хоть асинхронный код выглядит как последовательный, он стал компактнее и проще для понимания, разработчику всё равно понадобится потратить время чтобы разобраться с ним, понять, что на каком потоке выполняется, да и с точки зрения читабельности выглядит он мягко говоря не очень. И тут нам на помощь приходят корутины. Давайте рассмотрим, как данная задача будет реализована.

private fun loadImages() = CoroutineScope(Dispatchers.IO).launch {
    loadEmails()
    CoroutineScope(Dispatchers.Main).launch {
        emailView.setAdapter(ArrayAdapter<Any?>(applicationContext,
                android.R.layout.simple_dropdown_item_1line, getEmails()))
    }
}

Здесь количество строчек кода сократилось, асинхронный код выглядит как последовательный, пряча всю сложность внутри библиотек. По примеру видно, что код стал проще для понимания и чище, и сразу видно, что и в каком потоке выполняется.

Также стоит отметить что корутины это “легковесные потоки”, да это не те потоки, которые мы знаем в Java и в отличие от потоков можно создавать сколько угодно, они дешевые в создании т.е. накладные расходы в сравнении с потоками ничтожно малы. Также стоит сказать, что корутины предоставляют возможность запускать асинхронный код без всяких блокировок, что открывает больше возможностей для создания приложений. Простыми словами вычисления становятся прерываемыми, если возникает блокировка, например, из-за ожидания ответа на запрос получения списка почтовых адресов, корутина не простаивает, а берёт на себя другую задачу на выполнение до тех пор, пока ответ не будет получен.

Корутины это своего рода надстройка над потоками, они запускаются в потоках (выполняются в пуле потоков) под управлением библиотеки. Хотя с точки зрения пользователей корутины во многом ведут себя как обычные потоки, но в отличие от нативных потоков у них у них нет таких проблем как deadlocks, starvation и т.д.

Вывод

Из приведённых выше примеров вы наглядно увидели, что использование корутин делает код проще для понимания, асинхронный код визуально выглядит как последовательный. Корутины дешёвые в создании, их можно создавать тысячи, и они будут легко запущены. Корутины позволяют избежать блокировки потоков что позволяет реализовать прерываемые вычисления.

Если же смотреть в сторону RX Java, то там есть методы subscribeOn() и observeOn(), которые указывают в каком потоке мы получаем данные, а в каком их отображаем во view’шках. И читая код написанный на RX Java, разработчику не сразу становиться понятно в каком потоке что выполняется, ему требуется время на понимание всего этого. Другое дело корутины где всё сразу четко и понятно, и это вы можете убедиться на приведённых мною выше примерах. Попробуйте теперь посмотреть примеры RX и корутин в таком ключе.

Посмотрите ещё раз пример написанный на RX, сразу ли вам будет понятно какой блок кода выполняется в основном потоке, а какой в “io”. И взгляните на пример, написанный на корутинах где всё наглядно и очевидно.

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

Комментарии (16)


  1. GreenNick
    26.10.2021 23:02
    +3

    Извините, но код корутин ужасен :/


    1. AlexWoodblock
      27.10.2021 00:02
      +1

      Да и Rx-код тоже не подарок...

      Вообще, все сравнение очень странное - каждый раз, когда RxJava предлагают, как фреймворк для асинхронного программирования, хочется выть. Это как гвозди микроскопом забивать.


      1. dimskiy
        27.10.2021 08:43

        Так у автора и Rx код малость неумело написан :) Этот фреймворк редко кто знает в должной мере, чтобы не плодить ужасы. Самый большой минус Rx - это очень высокий порог входа. Именно поэтому он перестает быть популярен, что входящих в разработку стало ну много


    1. Nihiroz
      27.10.2021 08:11
      +1

      У автора он не очень аккуратно написан. Можно лучше:

      private suspend fun loadImages() {
          withContext(Dispatchers.IO) {
              loadEmails()
          }
          withContext(Dispatchers.Main) {
              emailView.setAdapter(
                  ArrayAdapter(
                      applicationContext,
                      android.R.layout.simple_dropdown_item_1line,
                      getEmails()
                  )
              )
          }
      }

      К тому же эту функцию стоит сделать `suspend`, т.к. в реализации автора возможна утечка памяти


      1. GreenNick
        27.10.2021 11:28

        Можно еще немного "улучшить".
        С большой долей вероятности, скоуп, в котором будет вызыватся эта функция уже будет построен на Main диспатчере (скоуп активити/фрагмента/вьюмодели). Соответсвенно, второе переключение контекста можно опустить.


        1. Nihiroz
          27.10.2021 11:32

          Я бы не стал закладываться на такое предположение. Но можно сделать лучше, вместо `Dispatchers.Main` написать `Dispatchers.Main.immediate`. Так, если, поток уже Main, то переключения не произойдет


          1. elzahaggard13
            29.10.2021 09:10

            Насколько я знаю, предложенное greennick выше является хорошим тоном, во фрагментах и активити вызывать корутины именно на мэйн


            1. Nihiroz
              29.10.2021 09:39

              В таком случае, возможно, стоит пометить метод аннотацией `@MainThread`. Но, мне кажется, что лучше дополнительно перестраховаться. Это не дорого, в случае использования `Dispatchers.Main.immediate` переключения потока не призойдет


    1. kolmand Автор
      27.10.2021 12:08

      Здесь дело не в коде, а в подходе, смысл статьи был показать что на корутинах код понятнее и читабельнее, код для статьи я писал на коленочке,


  1. dimskiy
    27.10.2021 09:06
    +3

    Минус Rx в том, что в нем довольно сложно разобраться так, чтобы писать аккуратный и понятный код. В вашем примере вы используете подход создания Observable, характерный для древней первой версии библиотеки. Сейчас это делается гораздо проще; более того, источники обычно создавать не нужно - Retrofit & Co давно умеют отдавать контракт Rx на выходе. К тому же, я так и не понял зачем в подписке создавать ArrayAdapter. Вы и про жизненный цикл не упомянули, и ваш пример будет утекать :\

    Вот так мог бы выглядеть ваш код (жизненный цикл не завозил за неимением деталей использования примера):

    abstract class Example {
    
            fun pullEmails() {
                Single.fromCallable { loadEmails() }
                    .subscribeOn(Schedulers.io())
                    .observeOn(AndroidSchedulers.mainThread())
                    .subscribe(this::setViewItems)
            }
    
            abstract fun loadEmails(): List<String>
    
            abstract fun setViewItems(items: List<String>)
        }

    Если же loadEmails ведет, например, в ретрофит, то даже fromCallable() не нужен

    Но в реальных проектах все чаще вижу как раз неумелое использование Rx, где в subscribe вкорячивают лямбду на 200 строк и творят другую дичь. Просто сборник "как делать в Rx не надо"


    1. kolmand Автор
      27.10.2021 12:11

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


      1. dimskiy
        27.10.2021 12:47
        +2

        Ну, так можно любой фреймворк выставить в неприглядном свете, если не заморачиваться :)


  1. mcpro
    27.10.2021 14:08

    Не увидел преимуществ. Везде, где речь идет о Java vs Kotlin, единственным преимуществом называют красивый и читабельный код. А здесь пример еще более странной борьбы за красоту кода в пределах Kotlin.
    Если программирую на Java, то у меня обычно нет проблем прочитать код.
    Главный вопрос, который должен подниматься при сравнении разных подходов — что будет работать быстрее, и на сколько быстрее. Читабельность кода сегодня, мне кажется, не так критична. Должен быть баланс между читабельностью, скоростью разработки и скоростью работы итогового приложения.


  1. FirsofMaxim
    27.10.2021 23:31

    А кто-нибудь понимает как сделать аналог Observable.amb (запуск нескольких операций паралельно, возврат результата только, как только сработает первая операция, остальные отменяются)?


    1. koperagen
      28.10.2021 05:07

      Можно так https://arrow-kt.io/docs/fx/parallel/index.html#racing-parallel-operations, см раздел raceN. В kotlinx.coroutines пока нет из коробки, но есть issue https://github.com/Kotlin/kotlinx.coroutines/issues/2867. Напишите туда, зачем такая функция нужна. Может быстрее добавят)


  1. sapeg
    01.11.2021 01:15

    С Handler'ом всё так. Про Rx не знал, но видимо и не нужно было. Корутины - интересно.