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

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<ResponseResult<List<FalconInfo>>>(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)
    }
}
Refactoring
sealed class Screen(
    val tabIndex: Int,
    val route: String,
    @StringRes val textResId: Int,
    val selectedItem: ImageVector,
    val unselectedItem: ImageVector,
    val popUp: Boolean = true
) {
    data object Home : Screen(HOME_TAB, HOME_SCREEN, R.string.home,
        Icons.Filled.Home, Icons.Outlined.Home)
    data object Rockets : Screen(ROCKETS_TAB, ROCKETS_SCREEN, R.string.rockets,
        Icons.Filled.Rocket, Icons.Outlined.Rocket)
    data object Login : Screen(LOGIN_TAB, LOGIN_SCREEN, R.string.login,
        Icons.Filled.AccountCircle, Icons.Outlined.AccountCircle)

    companion object {
        const val HOME_SCREEN = "homeScreen"
        const val ROCKETS_SCREEN = "rocketsScreen"
        const val LOGIN_SCREEN = "loginScreen"

        const val HOME_TAB = 0
        const val ROCKETS_TAB = 1
        const val LOGIN_TAB = 2
    }
}



@Composable
private fun initTabItems(): List<TabItem> {
    val items = listOf(Screen.Home, Screen.Rockets, Screen.Login)

    return items.map {
        TabItem(title = stringResource(it.textResId),
            unselectedItem = it.unselectedItem,
            selectedItem = it.selectedItem)
    }
}

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<ApiService>()
    }

Интеграция в проект выглядит похожей на 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<ResponseResult<List<FalconInfo>>> {
        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 <T> Flow<ResponseResult<T>>.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<FalconInfoEntity>

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

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

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

Комментарии (11)


  1. Android1991
    06.10.2023 09:07
    +1

    как это тестировать?



  1. itmind
    06.10.2023 09:07

    В чем преимущество Ktorfit над Ktor ?

    Почему выбран Room? Он вроде не мультиплатформенный.


    1. app-z Автор
      06.10.2023 09:07

      Ktorfit использует Ktor. Чтобы переехать с Retrofit на Ktor быстро, можно использовать Ktorfit. Ktorfit это обертка на Ktor. Или можно так же эффективно на голом Ktor писать? поделитесь примером


    1. app-z Автор
      06.10.2023 09:07

      https://kotlinlang.org/docs/multiplatform-mobile-ktor-sqldelight.html

      Про Room это вы правильно заметили. Надо будет на досуге на SQLDelight перевести


  1. HemulGM
    06.10.2023 09:07
    +2

    25 строчек написал


    1. app-z Автор
      06.10.2023 09:07

      Мощно!

      Что это среда разработки и язык?


      1. HemulGM
        06.10.2023 09:07
        +1

        Delphi. RAD Studio 12

        Вот так на андроид


        1. app-z Автор
          06.10.2023 09:07

          На iPhone, осмелюсь предположить, так же будет?


          1. HemulGM
            06.10.2023 09:07
            +1

            Так точно)


            1. app-z Автор
              06.10.2023 09:07

              Borland Pascale multiplatform