Android Ktorfit+Compose

Архитектура приложения

Data+Domain

Data+Domain

Приложение состоит из одной Activity в которой создается GetFalconInfoUseCase

@AndroidEntryPoint
class MainActivity : ComponentActivity() {

    @Inject
    lateinit var getFalconInfoUseCase: GetFalconInfoUseCase

Далее этот UseCase передается в экран построения списка элементов. Используется producestate для преобразования non-Compose state into Compose state. Еще про «Побочные эффекты» возникающие при построении композиции можно посмотреть тут SideEffect

FalconInfoItemsScreen

@Composable
fun FalconInfoItemsScreen(getFalconInfoUseCase: GetFalconInfoUseCase) {
        produceState>>(initialValue = ResponseResult.Loading, getFalconInfoUseCase) {
            try {
                getFalconInfoUseCase.getFalconInfo().collect {
                    value = it
                }
            } catch (e: DataSourceException) {
                value = ResponseResult.Error(e)
            }
        }.let {
            when (it.value) {
            is ResponseResult.Loading -> ShowStatusScreen("Loading...")

            is ResponseResult.Success -> {
                it.value.onSuccess { rockets ->
                    FalconInfoListView(rockets)
                }
            }

            is ResponseResult.Error -> it.value.onError { error ->
                ShowStatusScreen(error.getError(LocalContext.current))
            }

        }
    }
}

Навигация

Настройка навигации заключается в установке пары route как строковой константы и compose элемента отрисовки. Также указываем первый экран startDestination

val navController = rememberNavController()
    NavHost(navController = navController, startDestination = LOGIN_SCREEN) {
        composable(HOME_SCREEN) { HomeScreen() }
        composable(ROCKETS_SCREEN) { FalconInfoItemsScreen(getFalconInfoUseCase) }
        composable(LOGIN_SCREEN) { LoginScreen() }
    }

Навигация по экранам в зависимости от выбранного таба

          when (selectedTabIndex) {
              HOME_TAB -> navigate(navController, Constants.HOME_SCREEN)
              ROCKETS_TAB -> navigate(navController, Constants.ROCKETS_SCREEN)
              LOGIN_TAB -> navigate(navController, Constants.LOGIN_SCREEN)
          }

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

fun navigate(navController: NavController, route: String) {
    navController.navigate(route) {
        popUpTo(0)
    }
}

Ktorfit

Традиционно используемый Retrofit заменен на Ktorfit. Поскольку даже названия двух клиентов похожи применение выглядит также. В описании так и написано — inspired by Retrofit. Аннотации сохранены без изменений. В проекте используется @GET.

Ktorfit is a HTTP client/Kotlin Symbol Processor for Kotlin Multiplatform (Android, iOS, Js, Jvm, Linux) using KSP and Ktor clients inspired by Retrofit

    @Singleton
    @Provides
    fun provideKtorfit(): Ktorfit {
        val ktorfit = ktorfit {
            baseUrl(Constants.BASE_URL)
            httpClient(HttpClient {
                // install(HttpCache)
                install(ContentNegotiation)
                {
                    json(
                        Json {
                            prettyPrint = true
                            isLenient = true
                            ignoreUnknownKeys = true
                        }
                    )
                }
            })
            converterFactories(
                FlowConverterFactory(),
                CallConverterFactory(),
            )

        }
        return ktorfit
    }

    @Singleton
    @Provides
    fun provideApiServices(ktorfitClient: Ktorfit): ApiService {
        return ktorfitClient.create()
    }

Интеграция в проект выглядит похожей на Retrofit. HttpClient и json serializer поставляются из Ktor пакета. Под капотом там package kotlinx.serialization.json. Converters гибко настраивается при желании. Например ниже список встроенных парсеров

Provides a list of standard subtypes of an application content type

public val Any: ContentType = ContentType("application", "*")
public val Atom: ContentType = ContentType("application", "atom+xml")
public val Cbor: ContentType = ContentType("application", "cbor")
public val Json: ContentType = ContentType("application", "json")
public val HalJson: ContentType = ContentType("application", "hal+json")
public val JavaScript: ContentType = ContentType("application", "javascript")
public val OctetStream: ContentType = ContentType("application", "octet-stream")
public val Rss: ContentType = ContentType("application", "rss+xml")
public val Xml: ContentType = ContentType("application", "xml")
public val Xml_Dtd: ContentType = ContentType("application", "xml-dtd")
public val Zip: ContentType = ContentType("application", "zip")
public val GZip: ContentType = ContentType("application", "gzip")

На GitHub Ktorfit есть два примера использования AndroidOnlyExample и MultiplatformExample. В проект добавлен первый вариант. В будущем можно перейти на использование KMM (Kotlin Multiplatform Mobile)

Кэширование результата запроса Ktorfit в Room

Логика выглядит так — Сначала проверяется пустая ли база данных. Если нет то данные извлекаются из нее. Если пустая то делается запрос в сеть и после перемапливания responseToDomain () отдается на уровень Domain и следом идет сохранение insertAllRocketsInfo () в базу данных для последующих запросов. Кэширование на уровне HTTP больше не требуется install (HttpCache). Работает такое кэширование с Базой данных значительное быстрее, поскольку не требуется Json парсинг

class FalconRepositoryImpl @Inject constructor(
    private val remoteDataSource: RemoteDataSource, private val localDataSource: LocalDataSource
) : FalconRepository {
    override fun getFalconInfo(): Flow>> {
        return flow  {
            if (localDataSource.getAllRocketsInfo().isNotEmpty()) {
                emit(ResponseResult.Success(
                    DataMapper.entryToDomain(localDataSource.getAllRocketsInfo())))
            } else {
                remoteDataSource.getFalconInfo().run {
                    when (this) {
                        is ResponseResult.Success -> {
                            emit(ResponseResult.Success(DataMapper.responseToDomain(response)))
                            localDataSource
                                .insertAllRocketsInfo(
                                    DataMapper.responseToEntry(response))
                        }
                        is ResponseResult.Error -> {
                            emit(ResponseResult.Error(exception))
                        }
                        else -> {}
                    }
                }
            }
        }.flowOn(Dispatchers.IO).onFlowStarts()
    }
}

onFlowStart () это extension для установки начального состояния ResponseResult.Loading перед тем как запустится Flow

/** extension function for Flow Class to emit loading state before the flow starts */
fun  Flow>.onFlowStarts() = onStart { emit(ResponseResult.Loading) }.catch { e: Throwable ->
    e.printStackTrace()
    emit(ResponseResult.Error(DataSourceException.Unexpected(R.string.error_client_unexpected_message)))
}

Карточки

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

Одна Card. Предпросмотр

Одна Card. Предпросмотр

Есть несколько других типов карточек ElevatedCard и OutlinedCard. Первая задает тень, вторая очерченная как это следует из перевода

ElevatedCard

ElevatedCard

material3 package-summary полный список дополнений Material Design 3

LazyVerticalGrid

Код в спойлере

@Composable
fun FalconInfoCard(falconInfo: FalconInfo) {
        Card(modifier = Modifier
            .fillMaxSize()
            .padding(4.dp),
            colors = CardDefaults.cardColors(
                containerColor = MaterialTheme.colorScheme.primary
            ),
                shape = RoundedCornerShape(30.dp),) {

            Row(
                modifier = Modifier
                    .padding(4.dp)
                    .fillMaxWidth(),
                    horizontalArrangement = Arrangement.Center,
            ) {
                  Text(
                      maxLines = 1,
                      text = falconInfo.name,
                      style = MaterialTheme.typography.titleSmall
                  )

            }

            Row(modifier = Modifier
                .fillMaxWidth(),
                horizontalArrangement = Arrangement.Center) {
                AsyncImage(
                    placeholder = rememberVectorPainter(Icons.Filled.Rocket),
                    model = falconInfo.links?.patch?.small,
                    contentDescription = null,
                    contentScale = ContentScale.FillWidth,
                    modifier = Modifier
                        .size(120.dp)
                        .clip(CircleShape)
                )
            }

            Spacer(modifier = Modifier
                .height(1.dp))

            Row(
                modifier = Modifier
                    .wrapContentHeight()
                    .padding(8.dp)
            ) {
                falconInfo.details?.let {
                    Text(
                        maxLines = 2,
                        overflow = TextOverflow.Ellipsis,
                        text = it,
                        style = MaterialTheme.typography.bodyMedium
                    )

                }
            }
        }
}

LazyVerticalGrid позволяет отрисовывать таблицы. Параметр columns устанавливает количество колонок. В моем случае их две columns = GridCells.Fixed (2). Цвет карточки задается через параметр containerColor. Используйте цвета из настроек вашей MaterialTheme, а не константы такие как Const.Blue и т.д. Например primary цвет для карточки задается так — containerColor = MaterialTheme.colorScheme.primary

База данных Room

Для простоты миграции при изменении версии базы она очищается. Метод cleanRockets () так же можно использовать для сброса кэша, например через WorkManager через временные интервалы или принудительно по своей кнопке в настройках

            override fun onDestructiveMigration(db: SupportSQLiteDatabase) {
                super.onDestructiveMigration(db)
                INSTANCE?.let {
                    scope.launch {
                        it.dao().cleanRockets()
                    }
                }
            }

...

    @Query("DELETE FROM FalconInfo")
    fun cleanRockets()

Класс-Таблица описания Базы Данных помечаем @Entityаннотацией

@Entity(tableName = "FalconInfo")

База данных имеет вложенную структуру, поэтому вложенные таблицы надо также отметить @Embeddedаннотацией. Room также поддерживает каскадное удаление. Этот функционал в проекте не используется

@Embedded("links") 
var linksEntry              : LinksEntry?              = LinksEntry(),

Простой запрос всех записей из Базы данных выглядит так

    @Query("SELECT * FROM FalconInfo")
    fun getAllRocketsInfo(): List

Исходный код проекта

Исходный код всего проекта можно посмотреть на GitHub в ветке ktorfit

Первая версия проекта описана в статье https://habr.com/ru/articles/763980/

© Habrahabr.ru