KA0712 — Создание сетей (Networking)

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

Получение данных из Интернета — одна из основных функций большинства мобильных приложений. В предыдущей главе вы узнали, как сериализовать и десериализовать данные JSON локально. Теперь вы узнаете, как делать несколько сетевых запросов и обрабатывать их ответы для обновления пользовательского интерфейса.

К концу главы вы будете знать, как:

  • Делайте сетевые запросы с помощью Ktor.
  • Разбирать сетевые ответы.
  • Протестируйте свою сетевую реализацию.

Необходимость в общей сетевой библиотеке

В зависимости от платформы, для которой вы разрабатываете, вы, вероятно, уже знакомы с Retrofit (Android), Alamofire (iOS) или Unirest (настольный компьютер).

К сожалению, эти библиотеки зависят от платформы и написаны не на Kotlin.

Примечание . В Kotlin Multiplatform вы можете использовать только библиотеки, написанные на Kotlin. Если библиотека импортирует другие библиотеки, разработанные на другом языке, ее нельзя будет использовать в мультиплатформенном проекте (или модуле).

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

Использование Ктора

Ktor — это библиотека с открытым исходным кодом, созданная и поддерживаемая JetBrains (и сообществом). Он доступен как для клиентских, так и для серверных приложений.

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

Примечание . Дополнительную информацию о Ktor можно найти на официальном сайте .

Добавление Ктора

Откройте build.gradle.kts из общего файла . Внутри commonMain dependenciesраздела добавьте в конце следующие зависимости:

implementation("io.ktor:ktor-client-core:2.0.0-beta-1") implementation("io.ktor:ktor-client-serialization:2.0.0-beta-1")

Здесь вы добавляете основную библиотеку Ktor вместе с библиотекой сериализации, которую она будет использовать для анализа ответов и преобразования данных в объекты, которые приложение может обрабатывать.

Ktor имеет разные клиентские механизмы HTTP в зависимости от платформы, для которой вы компилируете проект. Хотя для рабочего стола не требуется специальная библиотека, поскольку вы также ориентируетесь на Android и iOS, вам необходимо добавить следующее в androidMainи iosMainсоответственно:

implementation("io.ktor:ktor-client-android:2.0.0-beta-1")

implementation("io.ktor:ktor-client-ios:2.0.0-beta-1") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.0-native-mt") { version { strictly("1.6.0-native-mt") } }

Глядя на реализацию iOS, вы увидите, что вы также установили конкретную версию файла kotlinx-coroutines. Это необходимо, потому что версия сопрограмм, поставляемая вместе с Ktor, поддерживает только однопоточное использование. Подробнее об этом можно прочитать в главе 13 «Параллелизм».

Рекомендуемая версия для Kotlin 1.6.10 — использовать 1.6.0-native-mt.

Примечание . Вам нужно добавить эту версию в iOSMainраздел зависимостей, а не в него, commonMainпотому что ограничение связано с iOS.

Нажмите « Синхронизировать сейчас» , чтобы выполнить синхронизацию, и подождите, пока Android Studio извлечет и импортирует эти новые библиотеки.

Подключение к API с Ktor

Чтобы построить Learn , вам нужно сделать три разных запроса:

  • RSS-канал определенной темы.
  • Веб-страница статьи.
  • Ваш аккаунт Граватара.

Данные для первого находятся в RW_CONTENTсвойстве внутри файла FeedPresenter.kt , расположенного в общем модуле. Это может быть одно из следующих:

Каждый из этих запросов загружает последние 20 статей, опубликованных для его категории.

Второй запрос соответствует linkполю файла RWEntry. Поскольку запись RSS не содержит URL-адреса изображения статьи, вам нужно будет получить его вручную с сайта raywenderlich.com.

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

Как сделать сетевой запрос

Откройте папку данных внутри модуля shared/src/commonMain и создайте новый файл с именем FeedAPI.kt . Добавьте следующий код:

//1 public const val GRAVATAR_URL = "https://en.gravatar.com/" public const val GRAVATAR_RESPONSE_FORMAT = ".json" //2 @ThreadLocal public object FeedAPI { //3 private val client: HttpClient = HttpClient() //4 public suspend fun fetchRWEntry(feedUrl: String): HttpResponse = client.get(feedUrl) //5 public suspend fun fetchMyGravatar(hash: String): HttpResponse = client.get("$GRAVATAR_URL$hash$GRAVATAR_RESPONSE_FORMAT") }

При появлении запроса на импорт используйте следующее:

import io.ktor.client.HttpClient import io.ktor.client.request.get import io.ktor.client.statement.HttpResponse import kotlin.native.concurrent.ThreadLocal

В приведенном выше коде:

  1. Константы fetchMyGravatarбудут использоваться для выполнения запроса: URL-адрес и формат ответа.
  2. Эта аннотация действительна только для iOS (Kotlin/Native). Он игнорируется как в Android, так и на рабочем столе. Используя @ThreadLocal, FeedAPIон не будет использоваться другими потоками, которые пытаются получить к нему доступ. Вместо этого будет сделана новая копия. Это гарантирует, что объект не замерзнет. Подробнее об этом читайте в главе 13 «Параллелизм».
  3. Инициализация HttpClient, которую вы будете использовать для выполнения запросов.
  4. Эта функция получает URL-адрес фида для определенной темы, делает запрос и возвращает его в виде ответа через файл HttpResponse. В этом объекте можно получить дополнительную информацию о коде состояния ответа, его теле и т. д.
  5. Наконец, вы получите доступ к Gravatar для получения информации о вашем профиле.

Вы делаете запрос GET в Learn . Другие методы HTTP также доступны с Ktor: POST , PUT , DELETE , HEAD , OPTION и PATCH .

Примечание . Если вы внимательно посмотрите на эти функции, то увидите, что они объявлены с использованием ключевого слова suspend. Он используется, чтобы текущий поток не блокировался в ожидании ответа. Вы узнаете больше об этом и сопрограммах в главе 13, «Параллелизм».

Вы сделали запросы, и теперь пришло время обработать ответы.

Плагины

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

Эти плагины перехватывают все сделанные запросы и ответы, а затем обрабатывают их в соответствии с их назначением.

Парсинг сетевых ответов

Чтобы десериализовать ответ JSON, вам нужно добавить две новые библиотеки. Откройте файл build.gradle.kts и в разделе commonMain/dependencies добавьте:

implementation("io.ktor:ktor-client-content-negotiation:2.0.0-beta-1") implementation("io.ktor:ktor-serialization-kotlinx-json:2.0.0-beta-1")

Синхронизируйте проект.

В FeedAPI у вас есть две функции, которые возвращают HttpResponse:

  • fetchRWEntryполучает доступ к XML-каналу raywenderlich.com. Поскольку на данный момент нет прямого способа поддержать его сериализацию из Ktor или официальной библиотеки от JetBrains, вы будете использовать одну из библиотеки: KorIO .
  • fetchMyGravatarнастроен на получение ответа JSON, содержащего информацию о вашей учетной записи Gravatar.

Вы начнете с fetchMyGravatar. Поскольку это JSON, вы можете установить ContentNegotiationfor json, поэтому ответом от этой функции будет десериализованный объект. Для этого обновите clientинициализацию с помощью:

private val client: HttpClient = HttpClient { install(ContentNegotiation) { json(nonStrictJson) } }

Ktor теперь будет использовать jsonдля десериализации тела ответа. Кроме того, вам также необходимо определить nonStrictJsonсвойство. Объявите это перед HttpClient:

private val nonStrictJson = Json { isLenient = true; ignoreUnknownKeys = true }

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

import io.ktor.client.plugins.ContentNegotiation import io.ktor.serialization.kotlinx.json.json import kotlinx.serialization.json.Json

Чтобы ваше приложение оставалось стабильным при любом будущем обновлении сервера, это всегда хороший подход к определению isLenientи ignoreUnknownKeysкак истинный. В противном случае десериализация может вызвать исключение, если введенные данные искажены или в JSON есть свойства, которых нет в сериализуемом объекте.

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

public suspend fun fetchMyGravatar(hash: String): GravatarProfile = client.get("$GRAVATAR_URL$hash$GRAVATAR_RESPONSE_FORMAT").body()

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

Чтобы установить GravatarProfileи GravatarEntryкак Serializable, откройте файл GravatarEntry.kt в папке данных и добавьте аннотацию @Serializableк обоим классам данных.

Регистрация ваших запросов и ответов

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

Ktor имеет встроенную поддержку ведения журнала. Перед написанием логгера нужно открыть файл build.gradle.kts , и в зависимости commonMain добавить:

implementation("io.ktor:ktor-client-logging:2.0.0-beta-1")

Выполните синхронизацию Gradle.

Когда будете готовы, вернитесь к файлу FeedAPI.kt и добавьте следующий код в HttpClientлямбду инициализации:

//1 install(Logging) { //2 logger = Logger.DEFAULT //3 level = LogLevel.ALL }

Вот что происходит в приведенном выше коде:

  1. Вы устанавливаете Loggingфункцию в приложении. После установки он будет перехватывать все сетевые запросы и ответы.
  2. Это класс регистратора, который вы будете использовать для logвсех сетевых коммуникаций. Использование DEFAULTвозвращает к вызову printlnфункции.
  3. Это определило данные, которые должны быть зарегистрированы.

Различные типы уровней ведения журнала:

  • LogLevel.ALL: Где все логируется. Важно отметить, что при таком уровне журнала, если вы загружаете большой файл, все его содержимое будет напечатано. В конечном итоге это может привести к ошибке переполнения буфера и сбою вашего приложения. Не забудьте рассказать об этом сценарии.
  • LogLevel.HEADERS: регистрирует заголовки запроса и ответа.
  • LogLevel.BODY: При установленном уровне печатается только тело.
  • LogLevel.INFO: регистрирует URL-адрес и метод, который будет использоваться для запросов. Для ответов это означает его статус, метод и поле «от».
  • LogLevel.NONE: Ничего не будет зарегистрировано. В качестве механизма безопасности, если вы создаете свое приложение для производства, вам следует выбрать этот уровень. В противном случае вы рискуете, что кто-то может получить доступ к вашим сетевым журналам, просто открыв Logcat на устройстве, подключенном к компьютеру.

Кроме того, вы можете определить собственный класс регистратора. Для этого перейдите в папку данных внутри общего модуля и создайте файл HttpClientLogger.kt со следующим кодом:

import com.raywenderlich.learn.platform.Logger private const val TAG = "HttpClientLogger" public object HttpClientLogger : io.ktor.client.plugins.logging.Logger { override fun log(message: String) { Logger.d(TAG, message) } }

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

Теперь вернитесь в FeedAPI.kt и обновите ранее добавленный installвызов, чтобы вместо этого использовать:

logger = HttpClientLogger

Создайте и запустите приложения, чтобы убедиться, что все правильно. На данный момент, поскольку запросов нет, вы не найдете сообщения журнала при фильтрации HttpClientLogger как в Android Studio, так и в Xcode. После следующего раздела вы попробуете это снова.

Рис. 12.1 — Android Studio Logcat, отфильтрованный с помощью HttpClientLogger

Рис. 12.1 — Android Studio Logcat, отфильтрованный с помощью HttpClientLogger

Рис. 12.2 — Консоль Xcode, отфильтрованная HttpClientLogger

Рис. 12.2 — Консоль Xcode, отфильтрованная HttpClientLogger

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

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

Получение контента

Структура пакета Learn соответствует принципу чистой архитектуры и поэтому разделена на три уровня: данные , предметная область и представление . На уровне данных есть FeedAPI.kt , который содержит функции, отвечающие за выполнение запросов. Поднимитесь по иерархии и реализуйте уровни предметной области и представления. Пользовательский интерфейс будет взаимодействовать со слоем представления.

Взаимодействие с Граватаром

Откройте файл GetFeedData.kt в папке домена общего модуля. Внутри объявления класса замените TODOкомментарий на:

//1 public suspend fun invokeGetMyGravatar( hash: String, onSuccess: (GravatarEntry) -> Unit, onFailure: (Exception) -> Unit ) { try { //2 val result = FeedAPI.fetchMyGravatar(hash) Logger.d(TAG, "invokeGetMyGravatar | result=$result") //3 if (result.entry.isEmpty()) { coroutineScope { onFailure(Exception("No profile found for hash=$hash")) } //4 } else { coroutineScope { onSuccess(result.entry[0]) } } //5 } catch (e: Exception) { Logger.e(TAG, "Unable to fetch my gravatar. Error: $e") coroutineScope { onFailure(e) } } }

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

import com.raywenderlich.learn.platform.Logger

Вот что происходит:

  1. Эта функция получает hashсвойство, которое будет использоваться для построения запроса к Gravatar, и две лямбда-функции, которые будут вызываться в зависимости от того, успешна операция или нет. onSuccessсрабатывает для первого случая и onFailureдля второго.
  2. fetchMyGravatarиспользует ContentNegotiationранее установленный вами, поэтому вместо возврата HttpResponse(как это делают другие функции) он будет возвращать объект, содержащий данные ответа.
  3. Ответ действителен, если в result. Если этот список пуст, это означает, что ответ пуст и, следовательно onFailure, срабатывает.
  4. Однако, если он получает ответ, содержащий хотя бы одну запись, он onSuccessвызывается с первым объектом списка.
  5. Наконец, если во время этого процесса происходит сбой, onFailureвызывается исключение, вызвавшее проблему.

Теперь, когда логика предметной области готова, перейдите на уровень представления. Откройте FeedPresenter.kt . Перед объявлением класса добавьте и определите свой GRAVATAR_EMAIL:

private const val GRAVATAR_EMAIL = "YOUR_GRAVATAR_EMAIL"

Вы будете использовать это позже для создания запроса. Создайте учетную запись, если у вас ее еще нет, и замените YOUR_GRAVATAR_EMAILее адресом электронной почты Gravatar. После этого добавьте функции, которые пользовательский интерфейс будет вызывать внутри существующего класса:

`//1
public fun fetchMyGravatar(cb: FeedData) {
Logger.d(TAG, «fetchMyGravatar»)

//2
MainScope().launch {
//3
feed.invokeGetMyGravatar(
//4
hash = md5(GRAVATAR_EMAIL),
//5
onSuccess = { cb.onMyGravatarData(it) },
onFailure = { cb.onMyGravatarData(GravatarEntry()) }
)
}
}`

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

  1. Это функция, которая позволяет настроить прослушиватель для пользовательского интерфейса для получения обновлений для вызова fetchMyGravatar. Аргумент FeedData— это интерфейс, используемый для уведомления пользовательского интерфейса о появлении новых данных. Этот сценарий срабатывает onMyGravatarData.
  2. Поскольку invokeGetMyGravatarон объявлен с помощью suspendфункции, вам нужно вызвать его из сопрограммы. Для простоты в этой главе вы будете использовать MainScopeдля этого.
  3. Вызывает invokeGetMyGravatarдля запроса Gravatar.
  4. Для запроса Gravatar требуется хэш md5 электронной почты, которую зарегистрировал пользователь. Этот метод проще вызвать напрямую из Utils.kt .
  5. Если запрос выполнен успешно, он вызывает onSuccessвыражение с полученными данными. В противном случае срабатывает и отправляется onFailureпустой .GravatarEntry

Наконец, пришло время обновить приложения.

Перейдите в androidApp и в файле FeedViewModel.kt внутри папки ui/home обновите существующий fetchMyGravatar, чтобы вызвать точку входа, которую вы определили ранее:

fun fetchMyGravatar() { Logger.d(TAG, "fetchMyGravatar") presenter.fetchMyGravatar(this) }

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

override fun onMyGravatarData(item: GravatarEntry) { Logger.d(TAG, "onMyGravatarData | item=$item") viewModelScope.launch { _profile.value = item } }

Теперь, когда у вас все готово, создайте и запустите приложение для Android.

Рис. 12.3 — Toast в Android-приложении

Рис. 12.3 — Toast в Android-приложении

Чтобы реализовать ту же функцию в настольном приложении, откройте его FeedViewModel.kt , расположенный в папке ui/home модуля desktopApp . Аналогично тому, что вы добавили для Android, обновите существующую функцию до:fetchMyGravatar

fun fetchMyGravatar() { Logger.d(TAG, "fetchMyGravatar") presenter.fetchMyGravatar(this) }

Чтобы сделать соответствующие запросы и обновить, onMyGravatarDataчтобы уведомить пользовательский интерфейс, как только они станут доступны, обновите onMyGravatarDataметод следующим образом:

override fun onMyGravatarData(item: GravatarEntry) { Logger.d(TAG, "onMyGravatarData | item=$item") viewModelScope.launch { profile.value = item } }

Наконец, скомпилируйте и запустите приложение с помощью следующей команды:

./gradlew desktopApp:run

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

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

Переключитесь на Xcode и перейдите в папку extensions . Здесь откройте класс FeedClient.swift и найдите fetchProfileфункцию. Прежде чем назначать completion, handlerProfileдобавьте этот код, чтобы получить профиль Gravatar:

feedPresenter.fetchMyGravatar(cb: self)

Создайте и запустите приложение для iOS.

Рис. 12.5 — Toast в приложении для iOS

Рис. 12.5 — Toast в приложении для iOS

Взаимодействие с RSS-каналом raywenderlich.com

Теперь, когда вы получаете информацию от Gravatar, пришло время получить RSS-канал. Еще раз откройте файл GetFeedData.kt в общем/домене и добавьте следующее invokeGetMyGravatar:

//1 public suspend fun invokeFetchRWEntry( platform: PLATFORM, feedUrl: String, onSuccess: (List<RWEntry>) -> Unit, onFailure: (Exception) -> Unit ) { try { //2 val result = FeedAPI.fetchRWEntry(feedUrl) Logger.d(TAG, "invokeFetchRWEntry | feedUrl=$feedUrl") //3 val xml = Xml.parse(result.bodyAsText()) val feed = mutableListOf<RWEntry>() for (node in xml.allNodeChildren) { val parsed = parseNode(platform, node) if (parsed != null) { feed += parsed } } //4 coroutineScope { onSuccess(feed) } } catch (e: Exception) { Logger.e(TAG, "Unable to fetch feed:$feedUrl. Error: $e") //5 coroutineScope { onFailure(e) } } }

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

  1. Эта функция получает PLATFORMзначение перечисления, которое соответствует одной из различных областей статей на raywenderlich.com: all, Android, iOS, Unity и Flutter. Это используется, чтобы дать возможность пользовательскому интерфейсу фильтровать определенные типы.
  2. resultсодержит HttpResponseвозвращенное из fetchRWEntry. Отправляемый здесь параметр — это URL-адрес, по которому следует сделать запрос.
  3. Поскольку в Ktor нет прямой поддержки сериализации XML, вам необходимо использовать стороннюю библиотеку. В этом случае из-за его популярности вы будете использовать KorIO . Он проанализирует все узлы XML и вернет список RWEntry .
  4. Если все работало до этого следующего блока кода, эта функция завершается отправкой выражения feedto .onSuccess
  5. Напротив, если возникла какая-либо проблема, onFailureвместо этого срабатывает.

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

Для этого добавьте следующие функции выше fetchMyGravatar:

//1 public fun fetchAllFeeds(cb: FeedData) { Logger.d(TAG, "fetchAllFeeds") //2 for (feed in content) { fetchFeed(feed.platform, feed.url, cb) } } private fun fetchFeed(platform: PLATFORM, feedUrl: String, cb: FeedData) { MainScope().launch { //3 feed.invokeFetchRWEntry( platform = platform, feedUrl = feedUrl, //4 onSuccess = { cb.onNewDataAvailable(it, platform, null) }, onFailure = { cb.onNewDataAvailable(emptyList(), platform, it) } ) } }

Вот логическая разбивка:

  1. Вы будете использовать для уведомления пользовательского интерфейса о cbпоявлении новых данных.
  2. contentсоответствует десериализации RW_CONTENTсвойства. Он должен содержать пять различных типов платформ: все, Android, iOS, Unity и Flutter, каждый со своим собственным URL-адресом фида. Ты собираешься забрать их всех.
  3. invokeFetchRWEntryсобирается вызвать тот GetFeedData, который затем вызывает FeedAPIи отправляет сетевой запрос.
  4. Наконец, выражения onSuccessи onFailureвызывают cbфункции с данными ответа. В случае успеха операции RWEntryотправляется полученный список, в противном случае отправляется пустой список.

На этом вы завершили бизнес-логику (общую) для сетевых запросов. Пришло время подключить его к приложениям для Android, настольных компьютеров и iOS. Начиная с Android, откройте файл FeedViewModel.kt . Найдите fetchAllFeedsфункцию и добавьте следующий код внутри функции:

presenter.fetchAllFeeds(this)

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

override fun onNewDataAvailable(items: List<RWEntry>, platform: PLATFORM, exception: Exception?) { Logger.d(TAG, "onNewDataAvailable | platform=$platform items=${items.size}") viewModelScope.launch { _items[platform] = items } }

Это важно, потому что MainActivity.kt следит за всеми изменениями в items. Откройте этот класс, и вы увидите, что он itemsсодержит данные, необходимые для заполнения экранов приложения.

Создайте и запустите приложение для Android. Вы увидите экран, похожий на этот:

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

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

Перейдите к проекту desktopApp и добавьте ту же логику. В FeedViewModel.kt найдите fetchAllFeedsфункцию и добавьте:

presenter.fetchAllFeeds(this)

Main.kt вызывает эту функцию для получения всех доступных каналов. Когда они будут готовы, onNewDataAvailableвызывается со всеми предметами. Обновите эту функцию до:

override fun onNewDataAvailable(newItems: List<RWEntry>, platform: PLATFORM, exception: Exception?) { Logger.d(TAG, "onNewDataAvailable | platform=$platform items=${items.size}") viewModelScope.launch { _items[platform] = items } }

Теперь, когда настольное приложение готово, введите команду компиляции и запуска в терминале Android Studio:

./gradlew desktopApp:run

Вы увидите приложение, похожее на это:

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

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

Наконец, обновите приложение iOS. Откройте файл FeedClient в папке расширений и найдите fetchFeeds. Здесь, прежде чем присваивать completion, handlerдобавьте:

feedPresenter.fetchAllFeeds(cb: self)

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

Рис. 12.8 — Лента в приложении iOS

Рис. 12.8 — Лента в приложении iOS

Добавление заголовков к вашему запросу

У вас есть две возможности добавить заголовки к вашим запросам: определив их при настройке HttpClient или при индивидуальном вызове клиента. Если вы хотите применять его к каждому запросу, сделанному вашим приложением через Ktor, вам нужно добавить их при объявлении HTTP-клиента. В противном случае вы можете установить их по конкретному запросу.

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

Сначала создайте файл Values.kt в корневой папке модуля shared/commonMain . Он должен располагаться на одном уровне с доменом и платформой .

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

public const val X_APP_NAME: String = "X-App-Name"

Эта константа будет ключом заголовка в обеих реализациях.

Теперь определите его значение, добавив еще одно свойство — на этот раз оно должно соответствовать имени приложения:

public const val APP_NAME: String = "learn"

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

Теперь, если вы хотите добавить этот заголовок ко всем запросам, сделанным через Ktor, вам нужно найти его clientв файле FeedAPI.kt . Когда вы переопределяете client, перед вызовом installдобавить:

defaultRequest { header(X_APP_NAME, APP_NAME) }

Прямой вызов defaultRequestэквивалентен:

install(DefaultRequest)

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

Теперь скомпилируйте приложение для всех трех приложений. Открыв Logcat (Android), терминал (рабочий стол) и консоль Xcode (iOS), подтвердите в сообщениях журнала, что вы отправляете этот новый заголовок.

Рис. 12.9 — Android Studio Logcat, показывающий все запросы с определенным заголовком

Рис. 12.9 — Android Studio Logcat, показывающий все запросы с определенным заголовком

Рис. 12.10 — Терминал, показывающий все запросы с определенным заголовком

Рис. 12.10 — Терминал, показывающий все запросы с определенным заголовком

Рис. 12.11 — Xcode, показывающий все запросы с определенным заголовком

Рис. 12.11 — Xcode, показывающий все запросы с определенным заголовком

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

Напротив, если вы хотите добавить этот заголовок для определенного запроса, вам просто нужно переопределить его, HttpRequestBuilderчтобы установить. Вот реальный пример: представьте, что вы хотите добавить его только тогда, когда вы загружаете свой профиль Gravatar. Удалите ранее добавленный заголовок и в fetchMyGravatarобъявлении обновите его до:

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

При этом только этот запрос содержит X-APP_NAMEзаголовок.

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

Рис. 12.12 — Android Studio Logcat, показывающий запрос с определенным заголовком

Рис. 12.12 — Android Studio Logcat, показывающий запрос с определенным заголовком

Рис. 12.13 — Терминал, показывающий запрос с определенным заголовком

Рис. 12.13 — Терминал, показывающий запрос с определенным заголовком

Рис. 12.14 — Консоль Xcode, показывающая запрос с определенным заголовком

Рис. 12.14 — Консоль Xcode, показывающая запрос с определенным заголовком

Загрузка файлов

Имея в виду мультиплатформенность, загрузка файла может быть довольно сложной задачей, поскольку каждая платформа обрабатывает их по-разному. Например, Android использует Uri и класс File из Java, который не поддерживается в KMP (поскольку он не написан на Kotlin). В iOS, если вы хотите получить доступ к файлу, вам нужно сделать это через FileManager , который является проприетарным и специфичным для платформы.

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

Начните с создания класса данных, который будет представлять изображение. Перейдите в commonMain и внутри платформы создайте файл PlatformMediaFile.kt :

public expect class MediaFile public expect fun MediaFile.toByteArray(): ByteArray

Здесь вы определяете класс и функцию, которые будете использовать для представления файла. На уровне платформы будут определены MediaFileкласс и соответствующая функция.toByteArray

Имея это в виду, перейдите к androidMain . Внутри платформы создайте соответствующий actualфайл — PlatformMediaFile.kt :

public actual typealias MediaFile = MediaUri public actual fun MediaFile.toByteArray(): ByteArray = contentResolver.openInputStream(uri)?.use { it.readBytes() } ?: throw IllegalStateException("Couldn't open inputStream $uri")

Здесь вы определяете ссылку MediaFileкак MediaUri. При каждом MediaFileдоступе будут вызываться свойства и функции из MediaUri. Этот класс еще не существует. Вам нужно будет создать его, потому что для того, чтобы получить ByteArrayиз файла, Android должен получить доступ к openInputStreamfrom contentResolver, который существует только в контексте действия.

Внутри data/model создайте файл MediaUri.kt и добавьте:

public data class MediaUri(public val uri: Uri, public val contentResolver: ContentResolver)

Доступ к этому contentResolverсвойству осуществляется в toByteArray, из которого вы можете openInputStream.

После этого пришло время определить реализацию iOS. Создайте PlatformMediaFile.kt в пакете платформы внутри папки iosMain и добавьте:

public actual typealias MediaFile = UIImage public actual fun MediaFile.toByteArray(): ByteArray { return UIImageJPEGRepresentation(this, compressionQuality = 1.0)?.toByteArray() ?: emptyArray<Byte>().toByteArray() }

В этом случае MediaFileпредставляется как UIImage. Необходимые ByteArrayдля загрузки данные извлекаются из вызова UIImageJPEGRepresentation.

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

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

//1 public suspend fun uploadAvatar(data: MediaFile): HttpResponse { //2 return client.post(UPLOAD_AVATAR_URL) { //3 body = MultiPartFormDataContent( formData { appendInput("filedata", Headers.build { //4 append(HttpHeaders.ContentType, "application/octet-stream") }) { //5 buildPacket { writeFully(data.toByteArray()) } } }) } }

Вот что происходит:

  1. Вам нужно получить MediaFileфайл, содержащий ссылку на ваше изображение. Важной частью этого объекта является toByteArrayфункция, которая используется на 5.
  2. В clientэтом примере то же самое, что вы использовали до сих пор. Нет необходимости устанавливать дополнительные плагины или настраивать какую-либо конфигурацию.
  3. В этом случае файл будет отправлен через составной запрос, поэтому bodyзапрос должен содержать эту информацию.
  4. Большинство серверов требуют, чтобы запрос содержал тип содержимого файла — в данном случае application/octet-stream.
  5. В зависимости от общего размера файла может потребоваться отправить более одной части. Хотя результатом всегда является массив байтов, в зависимости от платформы, на которой работает ваше приложение, toByteArrayбудут вызываться разные функции.

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

Тестирование

Чтобы написать тесты для Ktor, вам нужно создать фиктивный объект HttpClient, а затем протестировать различные ответы, которые вы можете получить.

Прежде чем писать, вам нужно открыть файл build.gradle.kts из общего доступа и включить в него следующее commonTest:

implementation(kotlin("test-junit")) implementation("junit:junit:4.13.2") implementation("io.ktor:ktor-client-mock:2.0.0-beta-1")

Подождите, пока проект синхронизируется.

После завершения откройте commonTestи внутри shared создайте класс NetworkTests . Все ваши сетевые тесты будут здесь.

Перед созданием тестов необходимо смокать некоторые объекты. После объявления класса добавьте:

private val profile = GravatarProfile( entry = listOf( GravatarEntry( id = "1000", hash = "1000", preferredUsername = "Ray Wenderlich", thumbnailUrl = "https://avatars.githubusercontent.com/u/4722515?s=200&v=4" ) ) )

Это будет то GravatarProfile, что вы ожидаете получить при фиктивных сетевых вызовах.

Теперь вам нужно издеваться над файлом HttpClient. Добавьте его под только что вставленным кодом:

private val nonStrictJson = Json { isLenient = true; ignoreUnknownKeys = true } private fun getHttpClient(): HttpClient { //1 return HttpClient(MockEngine) { //2 install(ContentNegotiation) { json(nonStrictJson) } engine { addHandler { request -> //3 if (request.url.toString().contains(GRAVATAR_URL)) { respond( //4 content = Json.encodeToString(profile), //5 headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString())) } else { //6 error("Unhandled ${request.url}") } } } } }

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

  1. Модульные тесты должны быть смоделированы, поскольку вы не будете совершать никаких сетевых вызовов. Цель состоит в том, чтобы просмотреть все возможные сценарии и убедиться, что приложение ведет себя соответствующим образом. Для этого вы инициализируете HttpClientс помощью MockEngine.
  2. Чтобы создать действительный тест, вам нужно следовать той же конфигурации, которую вы использовали при определении запросов. В этом случае нужно использовать ContentNegotationплагин.
  3. Это HttpClientможет использоваться различными запросами, поэтому вам необходимо определить, кто сделал запрос и какой ответ должен быть создан.
  4. contentопределяет тело ответа .
  5. Определяет тип содержимого ответа.
  6. Генерирует ошибку, если URL-адрес запроса не соответствует ни одному из существующих условий.

Примечание . Иногда Android Studio не может разрешить encodeToString. Если это так, добавьте вручную import kotlinx.serialization.encodeToString.

Наконец, определив запрос и ответ, напишите следующий тест:

@Test public fun testFetchMyGravatar() = runTest { val client = getHttpClient() assertEquals(profile, client.request ("$GRAVATAR_URL${profile.entry[0].hash}$GRAVATAR_RESPONSE_FORMAT").body()) }

Тест проходит, если ответ, который он получает, совпадает с profileиздеваемым объектом; в противном случае это не удается.

Чтобы запустить тест, щелкните правой кнопкой мыши имя класса NetworkTests , затем щелкните «Выполнить ‘NetworkTests’» или при открытом файле просто щелкните зеленые стрелки, показанные рядом с тестом, и выберите android (:testDebugUnitTest) .

Испытание

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

Задача: отправьте имя пакета в заголовке запроса

Вы узнали, как определить заголовок в запросе. В этом примере вы отправляли имя приложения в качестве его значения. Что, если вы хотите вместо этого отправить имя пакета в Android или, если он работает в iOS, Bundle ID или, в случае Desktop, имя приложения?

Примечание . Вы должны реализовать эту логику в общем модуле.

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

  • Ktor — это набор сетевых библиотек, написанных на Kotlin. В этой главе вы узнали, как использовать Ktor Client для многоплатформенной разработки. Его также можно использовать независимо на Android или на рабочем столе. Есть также Ktor Server; который используется на стороне сервера.
  • Вы можете установить набор плагинов, которые дадут вам набор дополнительных возможностей: установка пользовательского логгера, сериализация JSON и т.д.

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

В этой главе вы увидели, как использовать Ktor для сетевых запросов в ваших мобильных приложениях. Здесь он используется вместе с мультиплатформой Kotlin, но вы можете использовать его в своем Android, настольном компьютере или даже в серверных приложениях. Чтобы узнать, как реализовать эти возможности на других платформах, вам следует прочитать Compose for Desktop или — если вы хотите использовать его на стороне сервера — посмотреть этот видеокурс . Кроме того, есть также учебник, посвященный интеграции Ktor с GraphQL , который может показаться вам интересным.

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