Предупреждение

Эта статья устарела и оставлена лишь в целях сохранения истории. Лучше ориентироваться на более новую версию

Введение

Я сейчас параллельно с изучением .NET работаю над мобильным приложением для своего проекта. Для разработки я выбрал новый фреймворк от Google Jetpack Compose в том числе потому, что не хочу тратить лишнее время на описание интерфейсов через XML или ручное управление состоянием приложения. Он предоставляет декларативный интерфейс для проектирования интерфейса через более удобный Kotlin.

В мире Android-разработки обычно используется Retrofit или OkHttp, но я решил взять Ktor от JetBrains. Практически никаких готовых туториалов по этому стеку не было, поэтому я пишу свой.

Установка зависимостей

Добавим в build.gradle.kts уровня модуля следующие зависимости:

build.gradle.kts
val ktorVersion = "2.3.4"
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 версия)

На момент написания этой статьи уже вышла версия ktor 2.3.5, но она вызывала странные ошибки компиляции, поэтому пришлось откатиться.

Настройка клиента

В Ktor создание клиента это ресурсоёмкий процесс, поэтому важно не закрывать его в течение всего жизненного цикла приложения. Можно было использовать Hilt, который уже использовался в проекте на момент добавления работы с сетью, но я взял более простой способ.

Kotlin позволяет создавать объекты, которые автоматически будут создаваться на старте приложения и всё время держаться в памяти. Поэтому добавим объект HttpClientFactory. У меня он расположен в пакете domain.common.network

HttpClientFactory.kt
6 collapsed lines
import io.ktor.client.HttpClient
import io.ktor.client.engine.android.Android
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.plugins.logging.LogLevel
import io.ktor.client.plugins.logging.Logging
import io.ktor.serialization.kotlinx.json.json
object HttpClientFactory {
var baseUrl = "" // Открытое поле, в котором содержится базовый URL
val httpClient: HttpClient by lazy { // Открытое поле с HTTP клиентом, который создастся при первом обращении к нему и потом будет поддерживаться в памяти
HttpClient(Android) { // Создаём клиент с плагином для работы в Android
install(Logging) { // Добавляем плагин логгирования
level = LogLevel.ALL // И включаем все уровни логгирования
}
install(ContentNegotiation) { // Добавляем плагин парсинга
json() // И включаем парсинг json
}
}
}
}

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

Организация типизированных запросов

Добавим класс Response, который будет содержать информацию о типах, в которые необходимо преобразовывать данные, пришедшие с сервера

Response.kt
sealed class Response<out T, out E> {
data class Success<T>(val body: T) : Response<T, Nothing>()
sealed class Error<E> : Response<Nothing, E>() {
data class HttpError<E>(val code: Int, val errorBody: E?) : Error<E>()
data object NetworkError : Error<Nothing>()
data object SerializationError : Error<Nothing>()
}
}

Напишем пару методов расширения для HTTP клиента.

Так, метод safeRequest поможет, используя стандартный синтаксис запросов ktor делать запросы в сеть, добавляя при этом обработку ошибок.

HttpClientExtensions.kt
9 collapsed lines
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.plugins.ClientRequestException
import io.ktor.client.plugins.ResponseException
import io.ktor.client.plugins.ServerResponseException
import io.ktor.client.request.HttpRequestBuilder
import io.ktor.client.request.request
import io.ktor.utils.io.errors.IOException
import kotlinx.serialization.SerializationException
suspend inline fun <reified T, reified E> HttpClient.safeRequest(block: HttpRequestBuilder.() -> Unit): Response<T, E> {
return try {
val response = request { block() }
Response.Success(response.body())
} catch (e: ClientRequestException) {
Response.Error.HttpError(e.response.status.value, e.errorBody())
} catch (e: ServerResponseException) {
Response.Error.HttpError(e.response.status.value, e.errorBody())
} catch (e: IOException) {
Response.Error.NetworkError
} catch (e: SerializationException) {
Response.Error.SerializationError
}
}

Метод errorBody для объекта исключения ResponseException позволяет получать корректное сообщение об ошибке.

HttpClientExtensions.kt
suspend inline fun <reified E> ResponseException.errorBody(): E? {
return try {
response.body()
} catch (e: SerializationException) {
null
}
}

Обработка ответов

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

processResponse.kt
fun <T, E> processResponse(
response: Response<T, E>
): T? {
return when (response) {
is Response.Success -> response.body
else -> null
}
}

Добавление моделей запросов/ответов

Для того, чтобы корректно работала сериализация моделей в JSON нужно пометить их аннотацией @Serializable:

Course.kt
3 collapsed lines
import kotlinx.serialization.Serializable
import ru.unilms.domain.common.serialization.UUIDSerializer
import java.util.UUID
@Serializable
data class Course(
@Serializable(UUIDSerializer::class)
val id: UUID,
val name: String,
val abbreviation: String,
val progress: Float,
val semester: Int,
val tutors: List<String>
)

Классы, которые не имеют собственной реализации в Kotlin, а просто взяты из Java не имеют своих сериализаторов, поэтому их нужно написать самостоятельно. Я для первичных ключей использую UUID, поэтому приведу реализацию его сериализатора:

UUIDSerializer.kt
6 collapsed lines
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())
}
}

Описание клиента

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

CoursesService.kt
interface CoursesService {
suspend fun getEnrolled(type: CourseType): Response<List<Course>, ErrorResponse>
suspend fun getCourseContents(courseId: UUID): Response<CourseContent, ErrorResponse>
suspend fun getTextContent(textId: UUID): Response<ByteArray, ErrorResponse>
suspend fun getTextContentInfo(textId: UUID): Response<TextContentInfo, ErrorResponse>
suspend fun getFileContentInfo(fileId: UUID): Response<FileContentInfo, ErrorResponse>
}

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

CoursesServiceImpl.kt
class CoursesServiceImpl(
private val token: String, // Конструктор класса принимает JWT токен, который используется для авторизации на бэкенде
) : CoursesService {
override suspend fun getEnrolled(type: CourseType): Response<List<Course>, ErrorResponse> {
val client = HttpClientFactory.httpClient // Получаем HTTP клиент
return client.safeRequest { // Вызываем наш метод расширения и внутри его лямбда-функции спокойно используем стандартный синтаксис запросов Ktor
method = HttpMethod.Get // Укажем HTTP метод
parameter("filter", type.value) // Добавим параметр запроса (url?filter=value)
url("${HttpClientFactory.baseUrl}/v2/courses/enrolled") // Укажем URL, на который нужно отправить запрос
accept(ContentType.Application.Json) // Укажем заголовок Content-Type, который скажет серверу, что нужно вернуть JSON. Так Ktor поймёт, что ответ нужно парсить как JSON
headers {
append(HttpHeaders.Authorization, "Bearer $token") // Добавим заголовок Authorization, в котором передадим токен
}
}
}
}

Выполнение запросов

Данные запрашиваются из viewmodels экранов с помощью асинхронных функций:

CoursesViewModel.kt
14 collapsed lines
import android.content.Context
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import ru.unilms.data.DataStore
import ru.unilms.domain.common.network.HttpClientFactory
import ru.unilms.domain.common.network.processResponse
import ru.unilms.domain.course.model.Course
import ru.unilms.domain.course.network.CoursesServiceImpl
import ru.unilms.domain.course.util.enums.CourseType
import javax.inject.Inject
@HiltViewModel // Внедряем вьюмодель с помощью Hilt
class CoursesViewModel @Inject constructor(@ApplicationContext private val context: Context) : // Внедряем контекст приложения во вьюмодель
ViewModel() {
var isLoading = false
private var store: DataStore = DataStore(context) // Моя собственная реализация пользовательского хранилища данных
private lateinit var service: CoursesServiceImpl // Поле для реализации сервиса, объект которого будет создан позже (во время создания объекта вьюмодели)
init { // Конструктор
viewModelScope.launch { // Асинхронный запуск
store.apiUri.collect { // Чтение из пользовательского хранилища базового URL сервера
HttpClientFactory.baseUrl = it // Сохраняем его в фабрике HTTP клиента
}
}
viewModelScope.launch {
store.token.collect { // Чтение из пользовательского хранилища токена
service = CoursesServiceImpl(it) // Создание объекта сервиса
}
}
}
suspend fun loadCourses(coursesType: CourseType = CourseType.Current): List<Course> { // Асинхронная функция, которая возвращает данные с сервера
var result: List<Course>? = null // Переменная под спаршенный результат
val response = service.getEnrolled(coursesType) // Вызов асинхронной функции
viewModelScope.launch {
isLoading = true
coroutineScope {
result = processResponse(response) // Обработка результата запроса
}
isLoading = false
}
return result ?: emptyList() // Передача результата на экран
}
}

Отображение данных с сервера на экране

CourseScreen.kt
10 collapsed lines
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.hilt.navigation.compose.hiltViewModel
import kotlinx.coroutines.launch
import ru.unilms.data.AppBarState
@Composable
fun CoursesScreen(navigate: (Screens, UUID?) -> Unit, onComposing: (AppBarState) -> Unit) {
val coroutineScope = rememberCoroutineScope()
val viewModel = hiltViewModel<CoursesViewModel>()
var courses: List<Course> by remember { mutableStateOf(emptyList()) }
fun updateCourses(type: CourseType = CourseType.Current) =
coroutineScope.launch { courses = viewModel.loadCourses(type) }
LaunchedEffect(true) { // Блок LaunchedEffect запускается один раз при первом рендере экрана и затем каждый раз, когда изменяется аргумент (true не меняется никогда)
onComposing( // С помощью этой функции я управляю состоянием верхнего бара
AppBarState(
actions = { }
)
)
updateCourses() // Вызов функции, которая выполнит запрос на сервер и обновит состояние экрана, добавив данные на экран
}
// Описание UI экрана
}