Идея написания данной статьи возникла у меня тогда, когда я начал изучать корутины, это был 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”. И взгляните на пример, написанный на корутинах где всё наглядно и очевидно.
Все эти плюсы говорят об одном что использование корутин это то что нужно мобильному разработчику при создании приложения.