Розробка сучасних Android-додатків на Kotlin

Автор Oznem, Груд. 31, 2025, 11:15 AM

« попередня теа - настпна тема »

Oznem

Вступ
У 2025 році розробка Android повністю базується на Kotlin + Jetpack Compose. Coroutines та Flow роблять асинхронні операції чистими, Hilt автоматизує впорскування залежностей, Room для локальної бази даних, Clean Architecture максимізує тестування. З Kotlin 2.3+ та Android Studio Meerkat+ навіть інтеграція KMP стала простою, але ми фокусуємося на чистому Android.

У цьому посібнику ми створимо з нуля додаток Погоди:
  • Пошук міста + додавання до улюблених
  • Інтеграція OpenWeatherMap API (з Flow)
  • Локальний кеш з Room + підтримка офлайн
  • UI Jetpack Compose (Material 3)
  • Hilt DI + Coroutines + StateFlow
  • Clean Architecture (data/domain/presentation)

Вимоги
Android Studio 2025.x, Kotlin 2.3+, minSdk 26+
Залежності Gradle (build.gradle.kts модуль app):
Код Select
dependencies {
    implementation("androidx.core:core-ktx:1.13.1")
    implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.6")
    implementation("androidx.activity:activity-compose:1.9.3")
    implementation(platform("androidx.compose:compose-bom:2025.xx.xx"))
    implementation("androidx.compose.ui:ui")
    implementation("androidx.compose.material3:material3")
    implementation("androidx.compose.ui:ui-tooling-preview")
    debugImplementation("androidx.compose.ui:ui-tooling")

    // Hilt
    implementation("com.google.dagger:hilt-android:2.52")
    ksp("com.google.dagger:hilt-compiler:2.52")
    implementation("androidx.hilt:hilt-navigation-compose:1.0.0")

    // Coroutines + Flow
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")

    // Room
    implementation("androidx.room:room-runtime:2.6.1")
    implementation("androidx.room:room-ktx:2.6.1")
    ksp("androidx.room:room-compiler:2.6.1")

    // Retrofit + Converter
    implementation("com.squareup.retrofit2:retrofit:2.11.0")
    implementation("com.squareup.retrofit2:converter-gson:2.11.0")
    implementation("com.squareup.okhttp3:okhttp:4.12.0")

    // Coil for images
    implementation("io.coil-kt:coil-compose:2.7.0")
}

Для Hilt додайте клас Application та @HiltAndroidApp.

1. Структура проекту (Clean Architecture)
Код Select
app/
|-- data/
|   |-- local/     # Room DAO, Entity
|   |-- remote/    # Retrofit API, DTO
|   |-- repository/# Impl Repository
|-- domain/
|   |-- model/     # Domain Entities
|   |-- repository/# Abstract Repository
|   |-- usecase/   # UseCases
|-- di/            # Hilt modules
|-- presentation/
|   |-- ui/        # Screens, Composables
|   |-- viewmodel/ # ViewModels
|-- util/

2. Шар Domain (Найчистіший)
domain/model/Weather.kt
Код Select
data class Weather(
    val city: String,
    val temperature: Double,
    val description: String,
    val icon: String,
    val timestamp: Long = System.currentTimeMillis()
)

domain/repository/WeatherRepository.kt
Код Select
interface WeatherRepository {
    suspend fun getWeather(city: String): Result<Weather>
    fun getFavoriteCities(): Flow<List<String>>
    suspend fun addFavorite(city: String)
    suspend fun removeFavorite(city: String)
}

Приклад UseCase: domain/usecase/GetWeatherUseCase.kt

Код Select
class GetWeatherUseCase @Inject constructor(
    private val repository: WeatherRepository
) {
    suspend operator fun invoke(city: String): Result<Weather> = repository.getWeather(city)
}


3. Шар Data
Віддалений API (Retrofit)
data/remote/WeatherApi.kt
Код Select
interface WeatherApi {
    @GET("weather")
    suspend fun getWeather(
        @Query("q") city: String,
        @Query("appid") apiKey: String,
        @Query("units") units: String = "metric"
    ): WeatherResponse
}

// DTO
data class WeatherResponse(
    val main: Main,
    val weather: List<WeatherItem>,
    val name: String
) {
    data class Main(val temp: Double)
    data class WeatherItem(val description: String, val icon: String)
}


Room Локальний
data/local/FavoriteCityEntity.kt
Код Select
@Entity(tableName = "favorite_cities")
data class FavoriteCityEntity(
    @PrimaryKey val city: String
)

data/local/FavoriteDao.kt

Код Select
@Dao
interface FavoriteDao {
    @Query("SELECT city FROM favorite_cities")
    fun getAll(): Flow<List<String>>

    @Insert(onConflict = OnConflictStrategy.IGNORE)
    suspend fun insert(city: FavoriteCityEntity)

    @Delete
    suspend fun delete(city: FavoriteCityEntity)
}


Реалізація Repository
data/repository/WeatherRepositoryImpl.kt
Код Select
@Singleton
class WeatherRepositoryImpl @Inject constructor(
    private val api: WeatherApi,
    private val dao: FavoriteDao,
    private val apiKey: String // @Named або з BuildConfig
) : WeatherRepository {
    override suspend fun getWeather(city: String): Result<Weather> = try {
        val response = api.getWeather(city, apiKey)
        val weather = Weather(
            city = response.name,
            temperature = response.main.temp,
            description = response.weather.firstOrNull()?.description ?: "",
            icon = response.weather.firstOrNull()?.icon ?: ""
        )
        Result.success(weather)
    } catch (e: Exception) {
        Result.failure(e)
    }

    override fun getFavoriteCities(): Flow<List<String>> = dao.getAll()

    override suspend fun addFavorite(city: String) {
        dao.insert(FavoriteCityEntity(city))
    }

    override suspend fun removeFavorite(city: String) {
        dao.delete(FavoriteCityEntity(city))
    }
}


4. Модулі Hilt
di/AppModule.kt
Код Select
@Module
@InstallIn(SingletonComponent::class)
object AppModule {
    @Provides
    @Singleton
    fun provideRetrofit(): Retrofit = Retrofit.Builder()
        .baseUrl("https://api.openweathermap.org/data/2.5/")
        .addConverterFactory(GsonConverterFactory.create())
        .build()

    @Provides
    @Singleton
    fun provideWeatherApi(retrofit: Retrofit): WeatherApi = retrofit.create(WeatherApi::class.java)

    // Room database vs.
}

5. ViewModel (StateFlow + Coroutines)
presentation/viewmodel/WeatherViewModel.kt
Код Select
@HiltViewModel
class WeatherViewModel @Inject constructor(
    private val getWeatherUseCase: GetWeatherUseCase,
    private val repository: WeatherRepository
) : ViewModel() {
    private val _uiState = MutableStateFlow(WeatherUiState())
    val uiState: StateFlow<WeatherUiState> = _uiState.asStateFlow()

    private val _favorites = MutableStateFlow<List<String>>(emptyList())
    val favorites: StateFlow<List<String>> = _favorites.asStateFlow()

    init {
        viewModelScope.launch {
            repository.getFavoriteCities().collect { cities ->
                _favorites.value = cities
            }
        }
    }

    fun searchCity(city: String) {
        viewModelScope.launch {
            _uiState.update { it.copy(isLoading = true) }
            getWeatherUseCase(city).fold(
                onSuccess = { weather ->
                    _uiState.update { it.copy(weather = weather, error = null) }
                },
                onFailure = { e ->
                    _uiState.update { it.copy(error = e.message, isLoading = false) }
                }
            )
        }
    }

    fun toggleFavorite(city: String) {
        viewModelScope.launch {
            if (_favorites.value.contains(city)) {
                repository.removeFavorite(city)
            } else {
                repository.addFavorite(city)
            }
        }
    }
}

data class WeatherUiState(
    val weather: Weather? = null,
    val isLoading: Boolean = false,
    val error: String? = null
)

6. Екран Jetpack Compose
presentation/ui/WeatherScreen.kt
Код Select
@Composable
fun WeatherScreen(viewModel: WeatherViewModel = hiltViewModel()) {
    val state by viewModel.uiState.collectAsStateWithLifecycle()
    val favorites by viewModel.favorites.collectAsStateWithLifecycle()
    var city by remember { mutableStateOf("") }

    Column(modifier = Modifier
        .fillMaxSize()
        .padding(16.dp)) {
        TextField(
            value = city,
            onValueChange = { city = it },
            label = { Text("Пошук міста (наприклад: Київ)") },
            modifier = Modifier.fillMaxWidth()
        )
        Spacer(Modifier.height(8.dp))
        Button(onClick = { viewModel.searchCity(city) }) {
            Text("Пошук")
        }
        if (state.isLoading) {
            CircularProgressIndicator(modifier = Modifier.align(Alignment.CenterHorizontally))
        }
        state.weather?.let { weather ->
            Card(modifier = Modifier
                .fillMaxWidth()
                .padding(top = 16.dp)) {
                Column(modifier = Modifier.padding(16.dp), horizontalAlignment = Alignment.CenterHorizontally) {
                    Text(weather.city, style = MaterialTheme.typography.headlineMedium)
                    AsyncImage(
                        model = "https://openweathermap.org/img/wn/${weather.icon}@2x.png",
                        contentDescription = null,
                        modifier = Modifier.size(100.dp)
                    )
                    Text("${weather.temperature.toInt()}°C", style = MaterialTheme.typography.bodyLarge)
                    Text(weather.description.capitalize())
                    IconButton(onClick = { viewModel.toggleFavorite(weather.city) }) {
                        Icon(
                            imageVector = if (favorites.contains(weather.city)) Icons.Filled.Favorite else Icons.Outlined.Favorite,
                            contentDescription = "Улюблене"
                        )
                    }
                }
            }
        }
        state.error?.let {
            Text(it, color = MaterialTheme.colorScheme.error)
        }
        // Список улюблених...
    }
}

7. Поради для просунутих (2025)
  • Flow з offline-first: API > кеш > emit
  • Додати стрічку новин з Paging 3 + RemoteMediator
  • Прискорити процесор hilt з KSP
  • Додати Baseline Profiles + Macrobenchmark
  • Кастомні анотації з Kotlin Symbol Processing (KSP)