Содержание
Работая над сетевым взаимодействием в мобильных приложениях, я долго искал инструмент, который бы сочетал в себе простоту и мощь. И нашёл его в Ktor. В этой статье я поделюсь своим опытом использования Ktor и объясню, почему он стал моим фаворитом для создания надежных и эффективных сетевых решений в Kotlin-приложениях.
Установка зависимостей
В build.gradle.kts
уровня проекта добавим плагин сериализатора:
plugins {
// ...
kotlin("plugin.serialization") version "1.9.22" apply false
}
В build.gradle.kts
уровня модуля добавим следующие зависимости:
dependencies {
// ...
val ktorVersion = "2.3.8"
implementation("io.ktor:ktor-client-core:$ktorVersion") // Базовая клиентская библиотека
implementation("io.ktor:ktor-client-android:$ktorVersion") // Плагин для работы в Android
implementation("io.ktor:ktor-client-content-negotiation:$ktorVersion") // Плагины для парсинга JSON в модели и обратно
implementation("io.ktor:ktor-serialization-kotlinx-json:$ktorVersion")
implementation("io.ktor:ktor-client-serialization:$ktorVersion")
implementation("io.ktor:ktor-client-logging:$ktorVersion") // Плагин для логгирования
implementation("ch.qos.logback:logback-classic:1.2.11") // Драйвер логгирования (последняя подддерживаемая в Android версия)
}
Так же понадобится активировать плагин в файле уровня модуля:
plugins {
// ...
kotlin("plugin.serialization")
}
Настройка клиента
В этот раз я решил избавиться от механизма, при котором пользователь выбирает базовый URL сервера, на который делаются все запросы, поэтому зададим этот адрес прямо здесь константой и настроим клиент так, чтобы он всегда использовал этот адрес для всех запросов:
import io.ktor.client.HttpClient
import io.ktor.client.engine.android.Android
import io.ktor.client.plugins.DefaultRequest
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.plugins.defaultRequest
import io.ktor.client.plugins.logging.LogLevel
import io.ktor.client.plugins.logging.Logging
import io.ktor.serialization.kotlinx.json.json
object HttpClientFactory {
const val baseUrl = "demo.uni-edu.ru/api/v2"
val httpClient: HttpClient by lazy {
HttpClient(Android) {
install(Logging) {
level = LogLevel.ALL
}
install(ContentNegotiation) {
json()
}
install(DefaultRequest)
defaultRequest {
url(scheme = "https", host = baseUrl)
}
}
}
}
Lazy позволяет не создавать клиент на старте приложения. Он будет создан при первом обращении к полю. Так, мы сможем в любом месте приложения обращаться к одному и тому же клиенту.
Типизированные ответы
В новой версии моего API сервер всегда возвращает одну структуру, которую примерно можно описать так:
{
"value": {},
"status": 0,
"isSuccess": true,
"successMessage": "string",
"correlationId": "string",
"errors": ["string"],
"validationErrors": [
{
"identifier": "string",
"errorMessage": "string",
"errorCode": "string",
"severity": 0
}
]
}
При этом $.value
может быть как объектом, так и списком, в зависимости от конкретного метода API. Поэтому напишем обёртку с обобщённым параметром, объект которой будем создавать при парсинге ответа:
import kotlinx.serialization.Serializable
@Serializable
data class ResponseWrapper<T>(
val value: T?,
val status: Int,
val isSuccess: Boolean,
val successMessage: String,
val correlationId: String,
val errors: List<String>,
val validationErrors: List<ValidationError>
)
@Serializable
data class ValidationError(
val identifier: String,
val errorMessage: String,
val errorCode: String,
val severity: Int
)
Кроме того, понадобится упрощённая версия этого объекта, с которой уже и будет работать код, вызывающий сетевые запросы.
data class Response<T>(
val isSuccess: Boolean,
val value: T?,
val errorMessage: String?
)
Отправка запросов
Теперь добавим метод расширения safeRequest
к HttpClient
:
import io.ktor.client.HttpClient
import io.ktor.client.plugins.ClientRequestException
import io.ktor.client.plugins.ServerResponseException
import io.ktor.client.request.HttpRequestBuilder
import io.ktor.client.request.header
import io.ktor.client.request.request
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
import io.ktor.utils.io.errors.IOException
suspend inline fun <reified T> HttpClient.safeRequest(block: HttpRequestBuilder.() -> Unit): Response<T> {
return try {
val response = request {
block()
header(HttpHeaders.Accept, ContentType.Application.Json)
}
when (response.status) {
HttpStatusCode.OK -> Response(
isSuccess = true,
value = response.parseBody<T>()!!.value,
errorMessage = null
)
HttpStatusCode.Unauthorized -> Response(
isSuccess = false,
errorMessage = response.headers["www-authenticate"] ?: "Не авторизован", // Можно использовать заголовки ответа
value = null
)
HttpStatusCode.NotFound -> Response(
isSuccess = false,
errorMessage = "Не найдено: ${response.parseBody<T>()!!.errors[0]}", // Можно использовать тело ответа. О том, что такое parseBody — ниже
value = null
)
// Добавить другие обработчики по коду ответа
else -> {
Response(isSuccess = false, value = null, errorMessage = "Неизвестная ошибка")
}
}
// Обработчики исключений, которые могут произойти тоже возвращают такой же объект ответа
} catch (e: ClientRequestException) {
Response(isSuccess = false, value = null, errorMessage = "Ошибка на клиенте")
} catch (e: ServerResponseException) {
Response(isSuccess = false, value = null, errorMessage = "Ошибка на сервере")
} catch (e: IOException) {
Response(isSuccess = false, value = null, errorMessage = "Ошибка сети")
} catch (e: SerializationException) {
Response(isSuccess = false, value = null, errorMessage = "Ошибка парсинга данных")
}
}
Он позволяет безопасно делать запросы в сеть, обрабатывая исключения и возвращая разные объекты в зависимости от кода ответа
Кроме того, нужен ещё один метод, расширяющий HttpResponse, который парсит ответ из JSON-строки в объект ResponseWrapper<T>
:
import io.ktor.client.statement.HttpResponse
import io.ktor.client.statement.bodyAsText
import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.Json
suspend inline fun <reified T> HttpResponse.parseBody(): ResponseWrapper<T>? {
val json = Json { ignoreUnknownKeys = true }
return try {
val stringBody = bodyAsText()
json.decodeFromString<ResponseWrapper<T>>(stringBody)
} catch (e: SerializationException) {
null
}
}
Если ваш сервер не возвращает единую структуру для всех ответов, метод можно переписать так:
Выполнение запросов
Запросы будут выполняться в отдельных классах сервисов. Напишем один такой:
import io.ktor.client.request.accept
import io.ktor.client.request.headers
import io.ktor.client.request.setBody
import io.ktor.client.request.url
import io.ktor.http.ContentType
import io.ktor.http.HttpMethod
import io.ktor.http.contentType
import ru.aip.intern.domain.auth.data.LoginRequest
import ru.aip.intern.domain.auth.data.LoginResponse
import ru.aip.intern.networking.HttpClientFactory
import ru.aip.intern.networking.Response
import ru.aip.intern.networking.safeRequest
class AuthService(private val token: String) {
suspend fun logIn(request: LoginRequest, fcmToken: String): Response<LoginResponse> {
val httpClient = HttpClientFactory.httpClient
return httpClient.safeRequest {
method = HttpMethod.Post
url("/auth/log-in")
setBody(request)
contentType(ContentType.Application.Json)
accept(ContentType.Application.Json)
headers {
append("x-FCM-Token", fcmToken)
}
}
}
}
Каждый метод в сервисе — это неблокирующая функция, которая берёт общий объект HTTP клиента и использует его, чтобы вызвать safeRequest
. Она возвращает упрощённый Response<T>
, где T — это объект ответа, находящийся в $.value
исходного ответа.
Ответ всегда выглядит как data class с аннотацией @Serializable
:
import kotlinx.serialization.Serializable
@Serializable
data class LoginResponse(
val accessToken: String
)
Если в модели используются типы из Java, для которых в ktor нет своего сериализатора (например UUID, который я использую для идентификаторов), потребуется написать свой сериализатор:
import kotlinx.serialization.KSerializer
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import java.util.UUID
object UuidSerializer : KSerializer<UUID> {
override val descriptor = PrimitiveSerialDescriptor("UUID", PrimitiveKind.STRING)
override fun deserialize(decoder: Decoder): UUID {
return UUID.fromString(decoder.decodeString())
}
override fun serialize(encoder: Encoder, value: UUID) {
encoder.encodeString(value.toString())
}
}
На поле в модели сериализатор добавляется так:
@Serializable(UuidSerializer::class)
val id: UUID
Работа с ответами
Запросы выполняются во viewModel, внедряемой в экраны, которым это нужно
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.launch
import ru.aip.intern.domain.auth.data.LoginRequest
import ru.aip.intern.storage.DataStoreRepository
import javax.inject.Inject
@HiltViewModel
class LoginViewModel @Inject constructor(val storage: DataStoreRepository) : ViewModel() {
private val _email = MutableLiveData("")
val email: LiveData<String> = _email
private val _password = MutableLiveData("")
val password: LiveData<String> = _password
private val _snackbarMessage = MutableSharedFlow<String>()
val snackbarMessage = _snackbarMessage
private val _service = AuthService("") // нужно добавить код, относящийся к получению токена (например, из DataStore)
fun login(redirect: () -> Unit) {
viewModelScope.launch {
val request = LoginRequest(email.value!!, password.value!!)
var fcmToken = "" // Я использую Firebase для получения уведомлений, ниже должен быть код, получающий FCM токен
val response = _service.logIn(request, fcmToken) // Делаем запрос на сервер
if (response.isSuccess) { // Обрабатываем результат
// Сохраняем полученный токен в хранилище (`value` здесь не может быть равен `null`, так как запрос завершился успешно)
storage.saveApiKey(response.value!!.accessToken)
redirect()
} else {
// Показываем сообщение с ошибкой пользователю, если запрос провалился. `errorMessage` здесь аналогично не может быть равен `null`
triggerSnackbar(response.errorMessage!!)
}
}
}
private fun triggerSnackbar(message: String) {
viewModelScope.launch {
_snackbarMessage.emit(message)
}
}
}
Интерфейс пользователя
Ну и последний этап — добавим отображение ошибок на экране. Для этого Composable функция должна подписаться на изменения snackbarMessage из viewModel:
val viewModel: LoginViewModel = hiltViewModel()
LaunchedEffect(key1 = true) {
viewModel.snackbarMessage.collect { message ->
snackbarHostState.showSnackbar(message)
}
}
Здесь snackbarHostState
это аргумент, который приходит из главного компонента. Создаётся он так:
val snackbarHostState = remember { SnackbarHostState() }
Scaffold(
// ...
snackbarHost = {
SnackbarHost(hostState = snackbarHostState)
}
) { innerPadding ->
NavHost(/****/) {
composable(Screen.Login.name) {
LoginScreen(snackbarHostState)
}
}
}
Заключение
Вот так можно реализовать удобные походы в сеть с помощью ktor в вашем Android-приложении на Jetpack Compose. Полный исходный код приложения можно посмотреть в репозитории