Android Ktorfit+Compose
Архитектура приложения
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. Предпросмотр
Есть несколько других типов карточек ElevatedCard и OutlinedCard. Первая задает тень, вторая очерченная как это следует из перевода
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/