Annual Open Tech Conference - ISsoft Insights 2021. June 19. Learn more.
×Закрыть

Android Coroutine. Гид для начинающих

Підписуйтеся на Telegram-канал «DOU #tech», щоб не пропустити нові технічні статті.

Всем привет, меня зовут Артем Чепурной, я Technical Lead в CHI Software. В основном я занимаюсь разработкой приложений под ОС Android. Сейчас становится все более популярной тема coroutines (сопрограмм), а «боевого» материала для новичков немного. В этой статье я попробую очень вкратце рассказать про то, что такое Kotlin Coroutines и как их правильно совмещать с роботом.

Что у нас есть сейчас

Существует множество вариантов написания программ для Android, выполняющихся асинхронно. Посмотрим на самые известные:

Callback

fun signIn() {
    showSignInScreen { credentialsResult ->
        if (credentialsResult.isOk) 
            signIn(credentialsResult.value) { result ->
                if (result.isOk) showMainScreen()
            }
    }
}

Подразумевается, что управление контекстом выполнения происходит в вызываемых функциях. Там могут быть как Thread, так и пыльные AsyncTask.

Плюсы: используются известные всем конструкции языка.
Минусы: сложность поддержки, код норовит разрастись в правую часть окна.

Rx

fun signIn() = showSignInScreen()
    .flatMap(::signIn)
    .flatMap {
        showMainScreen()
    }

Плюсы: легкость поддержки.
Минусы: требует нетривиальных знаний.

kotlinx.coroutines

suspend fun signIn() { 
    val credentials = showSignInScreen()
    signIn(credentials)
    showMainScreen()
}

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

Сопрограммы

Следует сказать, что хотя в сфере разработке мобильных приложений о сопрограммах большинство узнали с приходом языка Kotlin, однако сам термин появился в 1958 году как способ построения программы на ассемблере. Публикация, где описывались принципы сопрограмм, увидела свет в 1963 году (Conway, Melvin E. «Design of a Separable Transition-diagram Compiler»).

Сопрограммы в сравнении с потоками

Если зайти в документацию на сайт Kotlin, там можно увидеть такую цитату: Essentially, coroutines are light-weight threads.

Это простое объяснение, но оно не совсем точное.

Многозадачность

Для начала нужно разобраться с терминологией:

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

Из-за того, что сопрограммы используют кооперативную многозадачность, а не вытесняющую (как потоки), они обеспечивают конкурентность. Это значит, что части сопрограмм могут быть выполнены параллельно без необходимости использовать синхронизацию.

Не нужно думать, что сопрограммы по определению не могут блокировать поток! Пока сопрограмма выполняется на каком-то из потоков, его можно считать заблокированным.

Легковесность

Здесь все очень просто. Каждый созданный поток (настоящий поток) имеет выделенную память под его стек, как правило, 1 Mб. Сопрограммы же (в текущей реализации kotlinx.coroutines) представляют собой лишь граф последовательности исполнения программы, а выполняться могут как на множестве потоков параллельно, так и на одном потоке.

Выходит, что если запустить одновременно сотню тысяч потоков, то для этого потребуется минимум около 97 Гб памяти (и система отличная от Mac OS). Если же запустить такое же количество сопрограмм, то нужен пул потоков количеством равным количеству ядер процессора. Потребление памяти при этом составит около пары десятков мегабайт. При этом уровень параллелизма будет одинаков, так как параллельно может выполняться только по одному потоку на ядро процессора.

kotlinx.coroutines

Если вы совсем незнакомы с этой библиотекой, то рекомендую сначала прочитать официальную документацию.

kotlinx.coroutines в Android

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

Когда вы пишете код, нужно подразумевать что ваши публичные suspend функции могут быть вызваны из любого потока, в том числе и из главного!

Domain layer

Помечаем методы Repository и UseCase ключевым словом suspend. Если мы переключаем контекст выполнения в UseCase, не лишним будет передать в конструктор соответствующие Dispatcher-ы. Это облегчит написание тестов в дальнейшем.

interface PostRepository { 
    suspend fun get(id: String): Post
}


class GetPostByIdUseCase(
    private val postRepository: PostRepository,  
) { 
    operator suspend fun invoke(id: String) =
        postRepository.get(id)
}

Data layer

Если мы используем хранилище, которое поддерживает kotlinx.coroutines, то нам всего лишь потребуется вызывать методы, которые помечены модификатором suspend.

class PostRepositoryRoom(
    val postDao: PostDao,
) : PostRepository { 
    override suspend fun get(id: String) = postDao.get(id)
}

Если нет, то обертку придется писать самим:

class PostRepositoryRoom(
    val postDao: PostDao,
) : PostRepository { 
    override suspend fun get(id: String) = 
        suspendCancellableCoroutine { continuation ->
            val request = postDao.get(
                onSuccess = {
                    continuation.resume(it)
                },
                onError = {
                    continuation.resumeWithException(it)
                },
            )          
  
            it.invokeOnCancellation { 
                request.cancel()
            }
        }
}

Помните, что #invokeOnCancellation() может быть вызван из любого потока. Не все библиотеки потокобезопасны!

Presentation layer

Lifecycle

Множество компонентов, например Activity/Fragment/ViewModel, предоставляют свой объект жизненного цикла. У этого объекта есть пара полезных extension функций для работы с сопрограммами:

val Lifecycle.lifecycleScope: LifecycleCoroutineScope 

Возвращает CoroutineScope с контекстом из SupervisorJob и Dispatchers.Main.immediate. Закрывается после того как Lifecycle пройдет стадию destroy.

fun Lifecycle.whenStateAtLeast(
    minState: Lifecycle.State,
    block: suspend CoroutineScope.() -> T
): T

Пример использования:

suspend fun foo() {
    val poll = getPoll()
    val result = lifecycle.whenStateAtLeast(Lifecycle.State.STARTED) {
        showPollForResult(poll)
    }
    sendPoll(poll)
}

Блок в методе whenStateAtLeast использует специальный PausingDispatcher, который проверяет находится ли Lifecycle в необходимом состоянии после возвращения из других сопрограмм:

lifecycle.whenStateAtLeast(Lifecycle.State.STARTED) {
    // state >= Lifecycle.State.STARTED is guaranteed!
    val r = showPollForResult(poll)
    withContext(Dispatcher.IO) {
        // state >= Lifecycle.State.STARTED is not guaranteed!
        // We can not access views here, as they might be 
        // destroyed (IO is just for demonstration).
    }
    // state >= Lifecycle.State.STARTED is guaranteed!
    toast("Yay!")
    r
}

Следует помнить про специальное исключение CancellationException при работе с этим методом:

lifecycle.whenStateAtLeast(Lifecycle.State.STARTED) {
    try {
        // state >= Lifecycle.State.STARTED is guaranteed!
        showPoll(poll)
    } catch (e: IOException) {
        // state >= Lifecycle.State.STARTED is guaranteed!
    } catch (e: Throwable) {
        // state >= Lifecycle.State.STARTED is not guaranteed!
        // Check kotlin.coroutines.cancellation / CancellationException
    } finally {
        // state >= Lifecycle.State.STARTED is not guaranteed!
    }
}
fun LifecycleCoroutineScope.launchWhen*(
    block: suspend CoroutineScope.() -> Unit
): Job

Создает Job, который выполнится один раз синхронно с изменением состояния жизненного цикла.

ViewModel

Хотя ViewModel и реализует интерфейс LifecycleOwner и, соответственно, имеет доступ к вышеперечисленным методам, однако рекомендую делать свой CoroutineScope с обработкой исключений:

abstract class BaseViewModel : ViewModel() {
    private val job = SupervisorJob()

    private val coroutineExceptionHandler =
        CoroutineExceptionHandler { _, throwable ->
            if (throwable !is CancellationException) 
                handleException(throwable)
        }

    val scope: CoroutineScope = object : CoroutineScope {
        override val coroutineContext: CoroutineContext
            get() = Dispatchers.Main.immediate + job + coroutineExceptionHandler
    }

    open fun handleException(e: Throwable) {} 

    override fun onCleared() {
        super.onCleared()
        job.cancel()
    }
}

В этом примере CoroutineScope будет отменен перед уничтожением ViewModel.

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

Для передачи данных от ViewModel к View будем использовать Flow. Не нужно делать публичными изменяемые типы (такие как MutableStateFlow) типы!

class SearchViewModel : BaseViewModel() {
    private val queryStateFlow = MutableStateFlow("")

    val queryFlow: Flow<String> get() = queryStateFlow

    val contentFlow: Flow<String> = queryFlow
        .debounce(100L)
        .map {
           // don’t forget to handle possible exceptions!          
           searchUseCase(it)
         }           

    fun postQuery(query: String) {
        queryStateFlow.value = query
    }
}

Проектируйте Flow так, чтобы данные в него поступали изнутри его скоупа. Вариант ниже — пример того как делать не желательно!

class SearchViewModel : BaseViewModel() {
    private val queryStateFlow = MutableStateFlow("")

    val queryFlow: Flow<String> get() = queryStateFlow

    private val contentStateFlow = MutableStateFlow("")

    val contentFlow: Flow<String> get() = contentStateFlow          

    fun postQuery(query: String) {
        queryStateFlow.value = query
        scope.launch {
            Val result = searchUseCase(it)
            contentStateFlow.value = result
        }
    }
}

Activity/Fragment

Здесь я бы рекомендовал использовать сопрограммы только для подписки на Flow соответствующих ViewModel. Можно изобрести свой велосипед, который переподписывается на Flow в #onStart(), а можно использовать мой (с недавних пор можно использовать Flow<T>#flowWithLifecycle()).

Будьте осторожны при использовании whenStateAtLeast для подписки на Flow — после #onStop() Flow останется активным, хоть и приостановленным из-за PausingDispatcher. Это может приводить к лишнему потреблению ресурсов телефона.

class SearchFragment : Fragment() {
    
    // ...
    
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        viewLifecycleOwner.bind(searchViewModel.queryFlow, editText, searchViewModel::postQuery)
        viewLifecycleOwner.bindIn(searchViewModel.contentFlow) {
            contentText.text = it
        }
    }
}

Подумайте дважды перед тем как создавать сопрограммы из этих компонентов. Скорее всего лучше перенести этот код в ViewModel.

Итого

Эта статья рассчитана на начинающих и тех, кто знает только ключевое слово suspend и одно из возможных правильных использований kotlinx.coroutines с Android. Прежде, чем копировать решения из этой статьи или любой другой вводной публикации о сопрограммах, я бы порекомендовал прочитать документацию несколько раз — сначала по диагонали, а затем подробно.

Если у вас возникли вопросы, оставляйте их в комментариях, и мы попробуем разобраться вместе :)

👍НравитсяПонравилось4
В избранноеВ избранном4
Подписаться на автора
LinkedIn

Похожие статьи

Допустимые теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Допустимые теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

А кто-то в проде уже переехал с LiveData на StateFlow? Были ли подводные камни?
Мне LiveData изначально не нравился из-за убогенького апи, но когда я пришел на проект, он уже был. StateFlow выглядит поинтереснее, но потыкать палкой никак руки не доходят.

Я давно переехал полностью а Flow — не жалуюсь.

Подписаться на комментарии