KA0713 — Параллелизм (Concurrency)

  • Запись изменена:08.12.2022
  • Post category:Публикации
  • Reading time:30 минут чтения

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

Примечание : Эта глава следует за проектом, который вы начали в Главе 12, «Сеть». Или вы можете использовать начальный проект этой главы.

Вот что вы будете делать в этой главе:

  • Вы узнаете, что такое сопрограммы и как их реализовать.
  • Вы включите новую модель памяти Kotlin/Native.

Необходимость структурированного параллелизма

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

![[Снимок экрана 2022-03-17 в 20.31.59.png]]

UI-threadsecondary threadtask#1#2#3segmentsstructured concurrencyscreen refresh ratecoroutine #1coroutine #2coroutine #3

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

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

Структурированный параллелизм недавно приобрел большую популярность с выпуском kotlinx.coroutines для Android и async/await для iOS — в основном из-за того, насколько легко теперь выполнять асинхронные операции.

Различные решения для параллелизма

Для Kotlin Multiplatform создан набор библиотек, поддерживающих параллелизм:

  • kotlinx.coroutines : самый популярный, в основном из-за его использования разработчиками Android и рекомендаций от JetBrains и Google. Он легкий, позволяет запускать несколько сопрограмм в одном потоке и поддерживает обработку и отмену исключений.
  • Reaktive : реализация реактивных расширений с использованиемшаблона Observable .
  • CoroutineWorker : поддерживает многопоточные сопрограммы.

В этой главе вы узнаете, как использовать kotlinx.coroutines . Спойлер: вы уже работали с сопрограммами раньше. :]

Если вы уже знакомы с сопрограммами, вы можете пропустить несколько следующих разделов и перейти к разделу «Структурированный параллелизм в iOS» или сразу к разделу «Работа с kotlinx.coroutines», чтобы узнать о следующих разработках в Learn .

Понимание kotlinx.coroutines

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

Откройте файл FeedPresenter.kt из папки shared/commonMain/presentation и найдите fetchAllFeedsи fetchFeed. В первой функции у вас есть:

for (feed in content) { fetchFeed(feed.platform, feed.url) }

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

Приостановить функции

Функции приостановки лежат в основе сопрограмм. Как следует из названия, они позволяют вам приостановить сопрограмму и возобновить ее позже, не блокируя основной поток.

Сетевые запросы являются одним из вариантов использования функций приостановки . Откройте файл FeedAPI.kt в папке shared/commonMain/data и посмотрите на объявление функций:

public suspend fun fetchRWEntry(feedUrl: String): HttpResponse = client.get(feedUrl) public suspend fun fetchMyGravatar(hash: String): GravatarProfile = client.get("$GRAVATAR_URL$hash$GRAVATAR_RESPONSE_FORMAT") { header(X_APP_NAME, APP_NAME) }.body()

Это все функции приостановки . Поскольку ответ может занять некоторое время, приложение не может заблокировать и дождаться возврата какой-либо из этих функций.

Это изображение определяет поток, который вызывается триггером fetchRWEntry.

UI-threadnew coroutinefetchFeed()MainScope().launch { //create coroutine }suspend fun// deserializing// notifying the UIsuspend funsuspendswaiting for server responseresumesrequesting RW feeds123546fetchFeed()fetchFeed()FetchRWEntry()invokeFetchRWEntry()invokeFetchRWEntry()

Рис. 13.2 — Диаграмма, показывающая различные этапы сетевого запроса с сопрограммами.

Точкой входа для всех платформ является fetchAllFeedsфункция из файла shared/commonMain/presentation/FeedPresenter.kt . После вызова он перебирает все RSS-каналы и вызывает fetchFeedкаждый из своих URL-адресов:

  1. Это тяжелая операция, которая может заблокировать пользовательский интерфейс. Чтобы избежать этого, вы будете делать это асинхронно. Создайте сопрограмму, вызвав launch.
  2. После запуска он вызывает invokeFetchRWEntryиз shared/commonMain/domain/GetFeedData.kt . Функция приостановки вызывает FeedAPI для выполнения запроса.
  3. Эта функция приостанавливается после выполнения запроса и ожидает, пока не будет получен ответ или не истечет время ожидания соединения.
  4. Это делается в отдельном потоке, поэтому пользовательский интерфейс не блокируется.
  5. После получения ответа fetchRWEntryвозобновляется и возвращается к invokeFetchRWEntry, который теперь может десериализовать полученную информацию.
  6. Когда этот процесс завершается, выполняются функции onSuccessили onFailure— в зависимости от результата — и пользовательский интерфейс получает обновление. Поскольку вы используете сопрограмму MainScope, launchэто означает, что она будет работать в потоке пользовательского интерфейса. Вы увидите это подробно в следующем разделе.

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

Область действия и контекст сопрограммы

Вернитесь на FeedPresenter.kt из shared/commonMain/presentation и найдите fetchFeedфункцию:

private fun fetchFeed(platform: PLATFORM, feedUrl: String, cb: FeedData) { MainScope().launch { // Call to invokeFetchRWEntry } }

Вы уже знаете, что launchсоздает новую сопрограмму, но что такое MainScope? Область сопрограммы — это место, где будет выполняться сопрограмма — в данном случае это будет основной поток.

Если вы откроете исходный код MainScope:

public fun MainScope(): CoroutineScope = ContextScope(SupervisorJob() + Dispatchers.Main)

Вы можете видеть, что ContextScopeон построен с использованием:

  • SupervisorJob: когда вы создаете сопрограмму, она возвращает значение Job, соответствующее ее экземпляру. Это позволяет вам отменить или узнать больше о его текущем состоянии:
    • isActive: если он запущен в данный момент.
    • isCompleted: Если вся его работа, а также его дети закончились. Более того, когда текущее задание будет отменено или завершится ошибкой, его значение будет истинным.
    • isCancelled: при отмене или сбое текущего задания.

Разница между a SupervisorJobи a Jobзаключается в том, что у них разные политики. В первом дочерние элементы ведут себя независимо — если один выйдет из строя, другие не пострадают, тогда как во втором, если родитель выйдет из строя, все его дочерние элементы будут отменены.

  • Dispatchers: определить, в каком потоке должна выполняться сопрограмма:
    • Default: использует общий пул потоков.
    • Main: имеет разное поведение в зависимости от платформы, которая работает в данный момент. В JVM и Android Mainсоответствует потоку пользовательского интерфейса и должен использоваться только для операций, обновляющих пользовательский интерфейс. На Native это то же самое, что и Defaultдиспетчер.
    • Unconfined: не имеет связанной политики многопоточности. Не переключается на какой-либо конкретный поток.
    • IO: следует использовать для длительных и тяжелых задач, поскольку это один общий пул потоков, оптимизированный для таких типов операций. В настоящее время он недоступен для iOS.

Когда вы создаете сопрограмму, вы должны определить диспетчер , в котором она должна работать, но вы всегда можете переключить контекст позже при ее выполнении, вызвав withContextс добавленным Dispatcherв качестве аргумента.

В fetchMyGravatarфункции из FeedPresenter.kt вы запускаете сопрограмму в основном потоке, хотя единственная часть, которую необходимо запустить, — это вызовы onSuccessи . onFailureВы можете обновить существующую функцию, чтобы использовать поток по умолчанию для сетевых запросов, а когда данные доступны, переключиться на основной поток, чтобы можно было обновить пользовательский интерфейс:

public fun fetchMyGravatar(cb: FeedData) { //1 CoroutineScope(Dispatchers.Default).launch { //2 val profile = feed.invokeGetMyGravatar( hash = md5(GRAVATAR_EMAIL) ) //3 withContext(Dispatchers.Main) { //4 cb.onMyGravatarData(profile) } } }

Вот что вы делаете:

  1. Создает новую сопрограмму в потоке из пула потоков по умолчанию и запускает ее. В идеале следует использовать диспетчер ввода -вывода. Однако он не поддерживается в iOS, поэтому вам потребуется создать для него логику для конкретной платформы, как вы увидите в разделе «Реализация диспетчеров: ввод-вывод для iOS».
  2. invokeGetMyGravatarявляется функцией приостановки. Когда есть запрос, он приостанавливается до тех пор, пока не будет ответа сервера. Как только это произойдет, сопрограмма возобновится.
  3. Пользовательский интерфейс может обновляться только из UI-потока, поэтому необходимо переключиться с Defaultдиспетчера на Mainтот. Это можно сделать только из сопрограммы.
  4. onMyGravatarDataтеперь вызывается из UI-потока, поэтому пользователь может видеть эти вновь полученные данные.

Вам также потребуется обновить invokeGetMyGravatarфункцию, чтобы она вместо этого возвращала результат. Откройте файл GetFeedData.kt из commonMain/domain и измените его на:

public suspend fun invokeGetMyGravatar( hash: String, ): GravatarEntry { return try { val result = FeedAPI.fetchMyGravatar(hash) Logger.d(TAG, "invokeGetMyGravatar | result=$result") if (result.entry.isEmpty()) { GravatarEntry() } else { result.entry[0] } } catch (e: Exception) { Logger.e(TAG, "Unable to fetch my gravatar. Error: $e") GravatarEntry() } }

Помимо MainScope, у вас также есть GlobalScope. Как правило, он используется в сценариях, где сопрограмма должна работать на протяжении всего выполнения приложения.

Вы должны быть особенно осторожны при использовании этой функции. Если сопрограмма не может завершиться, она будет продолжать использовать ресурсы, возможно, до тех пор, пока пользователь не закроет приложение.

Если вам нужно обновить пользовательский интерфейс и вы используете GlobalScope, вы должны переключиться на поток пользовательского интерфейса раньше. В противном случае при запуске приложения для iOS вы получите следующее исключение:

kotlin.native.IncorrectDereferenceException: незаконная попытка доступа к неразделяемому (…) из другого потока

У вас также есть coroutineScopeфункция, которая позволяет вам создать сопрограмму, но она использует родительскую область в качестве контекста. Он имеет некоторые особенности, а именно:

  • Если родитель отменяется, он отменяет все его дочерние элементы.
  • Только после того, как закончатся все дочерние элементы, родитель также может завершиться.

Строители корутин, объем и контекст

Вы видели, как запустить сопрограмму, вызвав launch. Эта функция является частью сборщиков сопрограмм :

  • runBlocking: блокирует текущий поток до тех пор, пока созданная им сопрограмма не завершится.

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

  • launch: Создает сопрограмму, не блокируя текущий поток. Вы можете определить CoroutineScope , откуда он должен запускаться. Эта область действия гарантирует параллелизм структуры — другими словами, сопрограмма завершается только после того, как все ее дочерние элементы завершили свои операции.
  • async: Похож на launchто, как он построен и как он работает. Он отличается типом возвращаемого значения тем, что в данном случае это не Job , а объект Deferred , который будет содержать будущий результат этой функции.

Вернитесь к fetchMyGravatarфункции и добавьте ниже:

private suspend fun fetchMyGravatar(): GravatarEntry { return CoroutineScope(Dispatchers.Default).async { feed.invokeGetMyGravatar( hash = md5(GRAVATAR_EMAIL) ) }.await() }

fetchMyGravatarтеперь suspendфункция. При таком подходе вам не нужны обратные вызовы onSuccessи onFailureдля обновления пользовательского интерфейса, поскольку вы собираетесь вернуть файл GravatarEntry. Вам нужно позвонить awaitв конце, чтобы вернуть его окончательное значение вместо Deferred .

Стоит отметить, что эта функция аналогична использованию withContext:

private suspend fun fetchMyGravatar(): GravatarEntry { return withContext(CoroutineScope(Dispatchers.Default).coroutineContext) { feed.invokeGetMyGravatar( hash = md5(GRAVATAR_EMAIL) ) } }

Основное различие между обоими вызовами заключается в том, что CoroutineScopeони не используют ту же область действия, что и вызывающий объект.

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

public fun fetchMyGravatar(cb: FeedData) { Logger.d(TAG, "fetchMyGravatar") CoroutineScope(Dispatchers.Default).launch { cb.onMyGravatarData(fetchMyGravatar()) } }

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

С этим изменением вам необходимо обновить вызывающую функцию fetchProfileиз RWEntryViewModel в приложении iOS, чтобы можно было успешно обновить пользовательский интерфейс:

func fetchProfile() { FeedClient.shared.fetchProfile { profile in Logger().d(tag: TAG, message: "fetchProfile: \(profile)") DispatchQueue.main.async { self.profile = profile } } }

Вам не нужно обновлять приложение для Android или настольное приложение, поскольку viewModelScope работает в потоке пользовательского интерфейса.

Примечание . В следующих разделах вы узнаете, что iOS по умолчанию является однопоточной. Только когда вы включите новую модель памяти Kotlin/Native, вы сможете использовать многопоточность. При этом, если вы хотите скомпилировать свое приложение сейчас, вам нужно заменить его Dispatchers.Defaultна Dispatchers.Main. Кроме того, вы можете реализовать диспетчер на уровне конкретной платформы, как вы увидите в разделе «Реализация диспетчеров: ввод-вывод для iOS».

Отмена сопрограммы

Хотя вы не собираетесь использовать его в Learn , стоит упомянуть, что вы можете отменить сопрограмму, вызвав cancel()объект Job , возвращаемый launch.

Если вы используете async, вам придется реализовать решение, подобное этому:

val deferred = CoroutineScope(Dispatchers.Default).async { feed.invokeGetMyGravatar( hash = md5(GRAVATAR_EMAIL) ) } //If you want to cancel deferred.cancel() //If you want to wait for the result deferred.await()

Когда вы отменяете сопрограмму, автоматически CancellationExceptionвыбрасывается a. Вы можете поймать его для реализации определенного поведения, которое может понадобиться вашему приложению, или для очистки ресурсов.

Структурированный параллелизм в iOS

У Apple есть похожее решение для структурированного параллелизма: async/await .

Примечание . async/await доступен только в том случае, если вы используете Xcode 13.2 или более позднюю версию и запускаете свое приложение на iOS 13 или более поздних версиях.

С async/await вам больше не нужно использовать обработчики завершения. Вместо этого вы можете использовать asyncключевое слово после объявления функции. Если вы хотите дождаться его возврата, добавьте awaitперед вызовом функции приостановки :

private func fetchMyGravatar() async -> GravatarEntry { return await feed.invokeGetMyGravatar( hash = md5(GRAVATAR_EMAIL) ) }

Что похоже на Kotlin:

private suspend fun fetchMyGravatar(): GravatarEntry { return withContext(Dispatchers.IO) { feed.invokeGetMyGravatar( hash = md5(GRAVATAR_EMAIL) ) } }

Следуя той же логике , что и функции приостановки , вы можете вызывать асинхронную функцию только из другой или из асинхронной задачи. В Kotlin это соответствует вызову функции из сопрограммы.

Свифт использует Task. Используя Task, предыдущий пример можно преобразовать в:

private func fetchMyGravatar() { Task { let profile = await feed.invokeGetMyGravatar( hash = md5(GRAVATAR_EMAIL) ) await profile } }

С kotlinx.coroutines это:

private suspend fun fetchMyGravatar() = { CoroutineScope(Dispatchers.IO).launch { async { feed.invokeGetMyGravatar( hash = md5(GRAVATAR_EMAIL) ) }.await } }

Использование kotlinx.coroutines

Пришло время обновить учиться . В предыдущей главе вы узнали, как реализовать сетевой уровень в мультиплатформе. Для этого вы добавили библиотеку Ktor и написали логику для получения RSS-канала raywenderlich.com и анализа его ответов, которые впоследствии обновляют пользовательский интерфейс.

Однако для этого раздела осталась небольшая деталь: Ktor собирается с использованием kotlinx.coroutines . Вот почему функции MainScope, launchи suspendкажутся знакомыми в разделе «Понимание kotlinx.coroutines».

Добавление kotlinx.coroutines в конфигурацию Gradle

Поскольку Ktor включает kotlinx.coroutines , когда вы добавляли эту библиотеку в проект, вы добавляли обе библиотеки в фоновом режиме.

Если вы хотите включить kotlinx.coroutines в свои проекты, вам нужно добавить:

implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.0")

Примечание . Версия 1.6.0. Вы должны использовать эту версию с Kotlin 1.6.0 или 1.6.10. Если вы используете другой, откройте раздел сведений о выпуске и подтвердите, какую версию компилятора Kotlin следует использовать.

При ориентации на iOS существует ряд ограничений : сопрограммы являются однопоточными. Он будет выпущен с новой моделью управления памятью для Kotlin/Native, о которой вы подробно прочтете позже в этой главе.

Чтобы решить эту проблему, существует native-mtветка, поддерживающая многопоточность в iOS. Эта зависимость уже есть в файле проекта build.gradle.kts из общего модуля:

implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.0-native-mt") { version { strictly("1.6.0-native-mt") } }

Поскольку в этом случае Ktor использует библиотеку kotlinx.coroutines , необходимо использовать strictlyфункцию, чтобы заставить его использовать эту native-mtветку вместо этого. В противном случае вы получите эту ошибку при запуске приложения iOS:

kotlin.Error: Ktor native HttpClient требует версию kotlinx.coroutines с native-mtсуффиксом (например 1.3.9-native-mt).

Если по какой-либо причине вы не можете использовать эту native-mtверсию в своем проекте и не используете Ktor, вам необходимо создать собственную реализацию Dispatchers.Main . В противном случае у вас могут возникнуть проблемы с приложением для iOS:

kotlin.IllegalStateException: цикла событий нет. Используйте runBlocking {…}, чтобы запустить его.

Это связано с тем, что iOS поддерживает сопрограммы только в основном потоке. Если вы попытаетесь использовать основной диспетчер, он вернется к Dispatchers.Default , поскольку он не поддерживается в основной версии.

Примечание . Согласно JetBrains , native-mtветка не будет доступна для версии kotlinx.coroutines 1.7.0 и новее. В настоящее время он объединен с 1.6.0. Тем не менее, многопоточность на iOS доступна только с новой моделью памяти Kotlin/Native.

Важно отметить, что хотя сопрограммы в iOS должны выполняться в основном потоке, это не означает, что они будут его блокировать. Различают два типа операций:

  • Блокировка : когда поток останавливается, ожидая какой-либо операции. Быстрым примером этого может быть вызов функции сна .
  • Suspending : сопрограмма приостанавливается, а сам поток продолжает работать. Это не блокирует поток, и в этом состоянии могут выполняться другие операции.

Внедрение диспетчеров: ввод-вывод для iOS

Хотя без новой модели памяти Kotlin/Native iOS является однопоточной, это не означает, что вы не можете использовать преимущества многопоточности на других платформах. Однако вам потребуется реализовать эту поддержку.

Перейдите в каталог домена внутри shared/commonMain и создайте новый файл: PlatformDispatcher.kt . Добавлять:

internal expect val ioDispatcher: CoroutineContext

И импорт:

import kotlin.coroutines.CoroutineContext

Вы объявляете это так, ioDispatcherпотому что требование запуска сопрограмм в основном потоке существует только для нативных. Для других платформ вы можете работать с пулами потоков Default или IO .

Теперь перейдите в androidMain и создайте пакет домена , а затем файл PlatformDispatcher.kt с actualреализацией ioDispatcher:

internal actual val ioDispatcher: CoroutineContext get() = Dispatchers.IO

И импорт:

import kotlinx.coroutines.Dispatchers import kotlin.coroutines.CoroutineContext

Вы можете скопировать эту папку и вставить ее в каталог desktopMain на том же уровне, что и platform . JVM поддерживает ту же версию, что и Android, поэтому вы можете использовать Dispatchers.Mainее для запуска своего кода в UI-потоке.

Теперь перейдите к iosMain и повторите предыдущие шаги. Создайте папку домена и файл PlatformDispatcher.kt и на этот раз добавьте:

package com.raywenderlich.learn.domain internal actual val ioDispatcher: CoroutineContext get() = IosMainDispatcher

Создайте файл IosMainDispatcher.kt внутри папки домена и определите IosMainDispatcherобъект:

public object IosMainDispatcher : CoroutineDispatcher() { override fun dispatch(context: CoroutineContext, block: Runnable) { dispatch_async(dispatch_get_main_queue()) { block.run() } } }

Это возможно только потому, что у вас есть доступ к подписям Objective-C из мультиплатформы. Функция dispatch_async, которую вы вызываете, относится к платформе iOS.

Наконец, импортируйте:

import kotlin.coroutines.CoroutineContext import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Runnable import platform.darwin.dispatch_async import platform.darwin.dispatch_get_main_queue

Теперь откройте файл FeedPresenter.kt из каталога commonMain/presentation и после объявления класса добавьте:

private val scope = CoroutineScope(ioDispatcher)

Примечание . Если вы обновили fetchMyGravatarфункцию в главе выше, вам также необходимо заменить CoroutineScopeвызов на scope.

CoroutineContext , используемый в этом случае, будет тем ioDispatcher, который вы только что определили. Замените все вызовы MainScope()на scopeпеременную, которую вы создали выше.

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

Рис. 13.3 – Лента в приложении для Android

Рис. 13.3 – Лента в приложении для Android

Рис. 13.4 – Лента в приложении для iOS

Рис. 13.4 – Лента в приложении для iOS

Рис. 13.5 — Лента в настольном приложении

Рис. 13.5 — Лента в настольном приложении

Устранение неполадок kotlinx.coroutines в iOS

Продолжая свое путешествие с мультиплатформой вне этой книги, вы, вероятно, обнаружите эту ошибку:

Неперехваченное исключение Kotlin: kotlin.native.concurrent.InvalidMutabilityException: попытка мутации заморожена

Это InvalidMutabilityExceptionозначает, что вы обращаетесь к объекту, принадлежащему другому потоку, что в настоящее время невозможно. Подтвердите, используете ли вы Dispatchers.Main или ioDispatcher , который вы создали ранее, для доступа к этому объекту.

Если вы все еще сталкиваетесь с проблемами:

  • Удалите папку сборки в корневом каталоге проекта.
  • Удалите папку сборки в общем каталоге в корневом каталоге проекта.

Замороженное состояние

В некоторых случаях вам может потребоваться заморозить объекты при запуске приложения для iOS, чтобы избежать упомянутой выше ошибки. После freeze()вызова над объектом он становится неизменяемым. Другими словами, его нельзя изменить — это позволяет использовать его в разных потоках.

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

Работа с kotlinx.coroutines

В приложении перейдите на последний экран. Вы увидите пару статей, сгруппированных по разным разделам, которые вы можете открыть и пролистать, но ни одна из них не имеет изображения. Пришло время изменить это!

Создание функции приостановки

Начните с открытия файла FeedAPI.kt из data/commonMain в общем модуле. После fetchRWEntryдобавь:

public suspend fun fetchImageUrlFromLink(link: String): HttpResponse = client.get(link) { header(HttpHeaders.Accept, "text/html") }

Он fetchImageUrlFromLinkполучает ссылку из статьи и возвращает исходный код страницы в виде файла HttpResponse. Он должен быть установлен как suspend, чтобы текущий поток не блокировался, пока он ожидает ответа сервера.

Примечание . Вам необходимо установить Acceptзаголовок в этом запросе, иначе сервер вернет ошибку 406, что неприемлемо.

Затем откройте файл GetFeedData.kt из папки shared/commonMain/domain и добавьте в класс следующий метод:

//1 public suspend fun invokeFetchImageUrlFromLink( link: String, //2 onSuccess: (String) -> Unit, onFailure: (Exception) -> Unit ) { try { //3 val result = FeedAPI.fetchImageUrlFromLink(link) //4 val url = parsePage(result.bodyAsText()) //5 coroutineScope { onSuccess(url) } } catch (e: Exception) { coroutineScope { onFailure(e) } } }

Вот пошаговая разбивка этой логики:

  1. invokeFetchImageUrlFromLinkустановлен как suspendпоскольку он будет вызывать FeedAPIдля получения исходного кода страницы.
  2. Функции onSuccessи onFailureопределяют поведение этой функции в зависимости от того, удалось ли получить изображение для статьи или нет.
  3. Использует FeedAPIKtor HttpClient для выполнения сетевого запроса.
  4. Поскольку не существует API для получения URL-адреса изображения, вы будете анализировать HTML-код и искать определенный тег изображения. Наряду с сетевым запросом это будет тяжелой задачей. Итак, эту логику нужно вызывать из сопрограммы.
  5. Создает coroutineScopeновую сопрограмму, используя ее родительскую область для запуска функций onSuccessили onFailureв зависимости от того, была ли операция успешной или нет.

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

Примечание . Хорошее эмпирическое правило для таких случаев — решить между всеми командами, которые собираются использовать общий модуль, что им удобнее. Это особенно важно для iOS-программистов, которые плохо знакомы с Kotlin и могут чувствовать себя перегруженными адаптацией к новому языку. Взаимодействие с вашим общим модулем должно быть таким же, как и с любой другой библиотекой, существующей для iOS.

Создание сопрограммы с запуском

Теперь, когда вы реализовали функции для запроса и разбора данных, вам осталось только создать сопрограмму, а ее… запустить. :]

Откройте файл FeedPresenter.kt внутри commonMain/presentation . В общем модуле и перед fetchMyGravatar(cb: FeedData)функцией добавьте:

public fun fetchLinkImage(platform: PLATFORM, id: String, link: String, cb: FeedData) { scope.launch { feed.invokeFetchImageUrlFromLink( link, onSuccess = { cb.onNewImageUrlAvailable(id, it, platform, null) }, onFailure = { cb.onNewImageUrlAvailable(id, "", platform, it) } ) } }

Как вы читали в этой главе, существуют альтернативы реализации сопрограммы. В этом подходе вы используете FeedDataпрослушиватель, определенный на уровне пользовательского интерфейса. После invokeFetchImageUrlFromзавершения он либо вызовет функции, onSuccessлибо onFailureкоторые, в свою очередь, вызовут onNewImageUrlAvailableобратный вызов в пользовательском интерфейсе с новыми полученными данными или с исключением в случае ошибки.

Теперь подключите пользовательский интерфейс вашего приложения к этой новой функции.

В androidApp и desktopApp изменения аналогичны. В обоих проектах перейдите в ui/home , откройте файл FeedViewModel.kt и обновите onNewImageUrlAvailableобратный вызов с помощью:

override fun onNewImageUrlAvailable(id: String, url: String, platform: PLATFORM, exception: Exception?) { viewModelScope.launch { Logger.d(TAG, "onNewImageUrlAvailable | platform=$platform | id=$id | url=$url") val item = _items[platform]?.firstOrNull { it.id == id } ?: return@launch val list = _items[platform]?.toMutableList() ?: return@launch val index = list.indexOf(item) list[index] = item.copy(imageUrl = url) _items[platform] = list } }

Когда этот метод получает новое urlзначение, элемент, которому он соответствует, обновляется. Обновление _itemsкарты автоматически обновляет пользовательский интерфейс.

Примечание : viewModelScopeвыполняется в потоке пользовательского интерфейса.

Внутри withContextфункции onNewDataAvailableдобавьте:

_items[platform] = if (items.size > FETCH_N_IMAGES) { items.subList(0, FETCH_N_IMAGES) } else{ items } for (item in _items[platform]!!) { fetchLinkImage(platform, item.id, item.link) }

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

Создайте fetchLinkImageфункцию:

private fun fetchLinkImage(platform: PLATFORM, id: String, link: String) { Logger.d(TAG, "fetchLinkImage | link=$link") presenter.fetchLinkImage(platform, id, link, this) }

fetchLinkImageвызывает fetchLinkImageиз созданного ранее файла FeedPresenter.kt .

В iosApp откройте файл FeedClient.swift , который находится в каталоге расширений, и найдите fetchLinkImage. Чтобы также вызывать fetchLinkImageиз класса FeedPresenter.kt , измените эту функцию на:

public func fetchLinkImage(_ platform: PLATFORM, _ id: String, _ link: String, completion: @escaping FeedHandlerImage) { feedPresenter.fetchLinkImage(platform: platform, id: id, link: link, cb: self) handlerImage = completion }

Скомпилируйте и запустите приложения для трех платформ и перейдите к последнему экрану.

Рис. 13.6 — Android-приложение: просмотр последних статей

Рис. 13.6 — Android-приложение: просмотр последних статей

Рис. 13.7 — Приложение для ПК: просмотр последних статей

Рис. 13.7 — Приложение для ПК: просмотр последних статей

Рис. 13.8 — Приложение для iOS: просмотр последних статей

Рис. 13.8 — Приложение для iOS: просмотр последних статей

Создание корутины с асинхронностью

В качестве альтернативы предыдущему подходу, когда вы используете обратные вызовы для уведомления пользовательского интерфейса о появлении новых данных, вы можете приостановить fetchLinkImageфункцию до тех пор, пока не будет получен окончательный результат. Для этого вам нужно использовать asyncвместо launch.

Вернитесь к файлу FeedPresenter.kt в commonMain/presentation в общем модуле и обновите функцию fetchLinkImage:

public suspend fun fetchLinkImage(link: String): String { return scope.async { feed.invokeFetchImageUrlFromLink( link ) }.await() }

Как видите, параметры и больше не нужны platform, idтак как вы собираетесь вернуть URL-адрес изображения, если оно существует. Функция asyncпозволяет вернуть объект, awaitожидая готовности ответа. Вместо возврата Deferred — в данном случае это будет Deferred .

Примечание . Параметр scope— это переменная, созданная в разделе «Реализация Dispatchers.Main для iOS». Если вы пропустили этот раздел, вы можете использовать MainScopeвместо него.

В зависимости от версии Android Studio, которую вы используете, вполне вероятно, что вам будет предложено заменить предыдущую реализацию на:

public suspend fun fetchLinkImage(link: String): String { return withContext(scope.coroutineContext) { feed.invokeFetchImageUrlFromLink( link ) } }

Оба подхода дают похожие результаты, но внутри они совершенно разные.

Вы можете удалить onNewImageUrlAvailableиз интерфейса FeedData.kt , расположенного в каталоге domain/cb .

Откройте GetFeedData.kt и обновите invokeFetchImageUrlFromLinkдо следующего:

public suspend fun invokeFetchImageUrlFromLink( link: String ): String { return try { val result = FeedAPI.fetchImageUrlFromLink(link) parsePage(result.bodyAsText()) } catch (e: Exception) { "" } }

Теперь пришло время обновить интерфейс! Вам нужно будет изменить способ вызова fetchLinkImageфункции:

  • Как в androidApp , так и в desktopApp перейдите к файлу FeedViewModel.kt внутри ui/home и замените существующую fetchLinkImageфункцию на:

private fun fetchLinkImage(platform: PLATFORM, id: String, link: String) { Logger.d(TAG, "fetchLinkImage | link=$link") viewModelScope.launch { val url = presenter.fetchLinkImage(link) val item = _items[platform]?.firstOrNull { it.id == id } ?: return@launch val list = _items[platform]?.toMutableList() ?: return@launch val index = list.indexOf(item) list[index] = item.copy(imageUrl = url) _items[platform] = list } }

Это код, который включает onNewImageUrlAvailable, наряду с вызовом presenter.fetchLinkImage. Поскольку вы больше не используете этот обратный вызов, вы можете удалить его.

  • Для iOSApp также необходимо обновить файл FeedClient.swift , который находится внутри папки extensions . Начните с обновления, FeedHandlerImageкоторому больше не нужно получать все свои параметры:

public typealias FeedHandlerImage = (_ url: String) -> Void

Обновите fetchLinkImageдо:

@MainActor public func fetchLinkImage(_ link: String, completion: @escaping FeedHandlerImage) { Task { do { let result = try await feedPresenter.fetchLinkImage(link: link) completion(result) } catch { Logger().e(tag: TAG, message: "Unable to fetch article image link") } } }

Поскольку теперь вы получаете доступ к функции приостановки из Swift, вам придется использовать ожидание, чтобы дождаться , пока результат будет доступен. @MainActorАннотация гарантирует выполнение в Taskпотоке пользовательского интерфейса. В противном случае у вас может быть InvalidMutabilityException.

Теперь удалите onNewImageUrlAvailableиз FeedClientрасширения в нижней части файла, так как этот обратный вызов больше не существует.

Поскольку эта функция должна быть объявлена ​​как @MainActorи id, platformи cbбольше не нужна, вам необходимо обновить fetchFeedsWithPreviewRWEntryViewModel.swift в корневой папке iosApp :

@MainActor func fetchFeedsWithPreview() { for platform in self.items.keys { guard let items = self.items[platform] else { continue } let subsetItems = Array(items[0 ..< Swift.min(self.fetchNImages, items.count)]) for item in subsetItems { FeedClient.shared.fetchLinkImage(item.link) { url in guard var list = self.items[platform.description] else { return } guard let index = list.firstIndex(of: item) else { return } list[index] = item.doCopy( id: item.id, link: item.link, title: item.title, summary: item.summary, updated: item.updated, imageUrl: url, platform: item.platform, bookmarked: item.bookmarked ) Logger().d(tag: TAG, message: "\(list[index].title)Updated to:\(list[index].imageUrl)") self.items[platform.description] = list } } } }

Скомпилируйте и запустите свое приложение, а также просмотрите великолепные иллюстрации статей на raywenderlich.com. :]

Рис. 13.9 — Android-приложение: просмотр последних статей

Рис. 13.9 — Android-приложение: просмотр последних статей

Рис. 13.10 — Приложение для ПК: просмотр последних статей

Рис. 13.10 — Приложение для ПК: просмотр последних статей

Рис. 13.11 — Приложение для iOS: просмотр последних статей

Рис. 13.11 — Приложение для iOS: просмотр последних статей

Новая модель памяти Kotlin/Native

В этой книге вы видели пару сценариев, в которых вам нужно было создать конкретную реализацию для iOS:

  • @ThreadLocal: Использование этой аннотации в объекте гарантирует, что он не будет использоваться другими потоками, пытающимися получить к нему доступ. Вместо этого будет создана новая копия, гарантирующая, что объект не зависнет (в разделе «Подключение к API с помощью Ktor» из главы 12 «Сеть»).
  • Dispatcher.Main: в iOS нет поддержки запуска сопрограммы непосредственно в потоке пользовательского интерфейса. Для этого вам потребуется реализовать диспетчер на уровне платформы, как вы читали в разделе «Реализация Dispatchers.Main для iOS» в этой главе.

Эта новая модель памяти Kotlin/Native направлена ​​на сокращение изменений, которые вам придется делать специально для iOS.

Хотя он все еще находится в экспериментальном состоянии, вы можете попробовать его в своих приложениях.

Включение новой модели памяти Kotlin/Native

Learn уже использует последние библиотеки, совместимые с новой моделью памяти Kotlin/Native:

  • kotlin-gradle-plugin: 1.6.10
  • ktor: 2.0.0-бета-1
  • coroutines-native-mt: 1.6.0

Вам все еще нужно использовать native-mtветку, потому что korioбиблиотека была построена с использованием более старой версии сопрограмм.

Откройте файл gradle.properties , расположенный в корневой папке, и добавьте:

#Enable Kotlin/Native Memory Model kotlin.native.binary.memoryModel=experimental kotlin.native.binary.freezing=disabled

Это активирует новую модель памяти и отключает замораживание. Вам нужно установить этот последний атрибут, потому что не все библиотеки полностью совместимы с новой моделью. Если вы не отключите freezing, в конечном итоге вы окажетесь в своем приложении InvalidMutabilityExceptionдля FreezingExceptioniOS.

Чтобы убедиться, что все работает должным образом, вы можете открыть файл PlatformDispatcher.kt в iosMain/domain в общем модуле и заменить геттер на:

get() = Dispatchers.Default

Dispatchers.IOДля Native по-прежнему нет , но теперь вы можете использовать Defaultвместо того, чтобы всегда использовать основной поток.

С этим изменением вам необходимо обновить вызывающую функцию fetchFeedsиз RWEntryViewModel в приложении iOS:

func fetchFeeds() { FeedClient.shared.fetchFeeds { platform, items in Logger().d(tag: TAG, message: "fetchFeeds: \(items.count) items | platform: \(platform)") DispatchQueue.main.async { self.items[platform] = items } } }

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

Рис. 13.12 — Приложение для iOS: просмотр последних статей

Рис. 13.12 — Приложение для iOS: просмотр последних статей

Испытание

Перед вами стоит задача применить на практике то, что вы узнали в этой главе. Если вы застряли на каком-либо этапе, взгляните на решения в материалах к этой главе.

Задача: получить изображения статьи из общего модуля

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

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

Запросы должны выполняться параллельно.

Ключевые моменты

  • Функция приостановки может быть вызвана только из другой функции приостановки или из сопрограммы.
  • Вы можете использовать launchили asyncдля создания и запуска сопрограммы.
  • Сопрограмма может запускать поток из пулов потоков Main , IO или Default .
  • Новая модель памяти Kotlin/Native позволяет запускать несколько потоков на iOS.

Куда пойти отсюда?

Вы узнали, как реализовать асинхронные запросы с помощью сопрограмм и как работать с параллелизмом. Если вы хотите глубже погрузиться в эту тему, попробуйте книгу Kotlin Coroutines, где вы можете более подробно прочитать о Coroutines, Channels и Flows в Android. Есть также Concurrency, который фокусируется на многопоточности в Swift, и Concurrency в Swift , который обучает вас новой модели параллелизма с синтаксисом async/away .

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