Демо-отчёт. Проект и код написаны для примера — в них специально оставлены частые ошибки тестовых заданий. Это не работа реального клиента.
CodeCheckupОтчёт по код-ревью

Приложение погоды — тестовое задание

Поиск города, прогноз на 5 дней, офлайн-кэш. Kotlin, Jetpack Compose, Hilt, Retrofit, Room.

Заявка
№DEMO
Стек
Android
Что проверяли
Тестовое задание
Уровень
Middle
Репозиторий
демо-проект
Ревьюер
Android-разработчик CodeCheckup
Дата
19 октября 2026

Главное за минуту

5Исправить обязательно
6Желательно поправить
3На будущее

Работа близка к хорошей: структура понятная, интерфейс аккуратный. До отправки исправьте 5 пунктов P1 — это около 3–4 часов.

  1. Уберите ключ API из кода и отзовите старый — P1-1. Секреты в репозитории проверяют первыми.
  2. Сделайте базу единственным источником данных — P1-2. Сейчас без сети экран пустой, хотя кэш есть. Проверяющий почти наверняка включит авиарежим.
  3. Почините поиск: ответы на старые запросы затирают новые — P1-3, а после поворота экрана пропадает введённый город — P1-4.
  4. Перепишите тесты ViewModel на фейковом репозитории — P1-5. ./gradlew test должен проходить без интернета и без Thread.sleep.
  5. Если останется время — уберите DTO из UI P2-2 и логирование тела запросов в релизе P2-1.

Ответ на ваш вопрос

«Не уверена в архитектуре и в том, как сделала кэш».

Архитектура — в порядке для тестового. MVVM с репозиторием, зависимости через Hilt, экраны на Compose без логики внутри. Переписывать структуру не нужно. Два точечных изменения сделают её заметно сильнее: база как единственный источник данных P1-2 и доменная модель вместо DTO на экране P2-2.

Кэш — главное, что стоит переделать. Сейчас он только записывается, а читается лишь после удачного запроса. Правильная схема — экран всегда показывает то, что лежит в базе, а сеть только обновляет базу:

Как пойдут данные после исправлений
Compose-экранForecastViewModel (StateFlow)WeatherRepository
WeatherRepositoryRoom: forecast(city): Flow — единственный источник
refresh(city)RetrofitRoom

Так офлайн-режим работает сам собой, а при ошибке сети экран показывает старые данные и плашку «Нет сети, данные от 14:20». Время последнего обновления храните в таблице (fetchedAt) — пригодится и для плашки, и чтобы не ходить в сеть чаще раза в 30 минут.

Сильные стороны

  • Понятная структура по слоям: data/, ui/, di/. Проверяющий за минуту найдёт нужный класс.
  • Hilt настроен правильно: @HiltViewModel, модули с @InstallIn(SingletonComponent::class), без ручных синглтонов и lateinit в Application.
  • Compose-экраны разбиты на небольшие функции без состояния с превью — CurrentWeatherCard, ForecastRow. Это хорошо смотрится в коде и показывает, что вы понимаете hoisting состояния.
  • Retrofit-интерфейс на suspend-функциях, без колбэков и enqueue.
  • Честный README: как запустить, скриншоты и раздел «Что не успела». Об этом стоит упомянуть в сопроводительном письме — такой README выгодно выделяет работу.

Соответствие требованиям задания

Требование Статус Комментарий
Поиск города частично частично работает, но ответы гоняются — P1-3; после поворота пропадает ввод — P1-4
Прогноз на 5 дней выполнено группировка по дням и мин/макс температура считаются верно
Офлайн-кэш частично частично данные сохраняются, но без сети не показываются — P1-2
MVVM выполнено с оговоркой: DTO доходит до экрана — P2-2
Тесты на ViewModel частично частично тесты есть, но ходят в сеть и ждут Thread.sleep — P1-5

Замечания

Безопасность

Исправить обязательноP1-1

Ключ API лежит в коде и в истории git

Где
data/remote/WeatherApi.kt:12
Время
~20 мин

Что не так. Ключ погодного сервиса записан значением по умолчанию в интерфейсе Retrofit и попал в коммиты.

Почему это важно. Поиск секретов в репозитории — одна из первых проверок. Удаление строки не поможет: ключ останется в истории и его можно достать.

Было · WeatherApi.kt
interface WeatherApi {
    @GET("forecast")
    suspend fun forecast(
        @Query("q") city: String,
        @Query("appid") key: String = "3f9a1c7e5b2d4f6a8c0e1b3d5f7a9c2e",
    ): ForecastDto
}

Как исправить. Отзовите этот ключ в кабинете сервиса и получите новый. Новый положите в local.properties (он уже в .gitignore), передайте через BuildConfig и добавляйте к запросам интерцептором:

Стало · app/build.gradle.kts
val localProps = Properties().apply {
    rootProject.file("local.properties").takeIf { it.exists() }?.inputStream()?.use(::load)
}

android {
    buildFeatures { buildConfig = true }
    defaultConfig {
        buildConfigField("String", "WEATHER_API_KEY", "\"${localProps.getProperty("weather.apiKey", "")}\"")
    }
}
Стало · data/remote/ApiKeyInterceptor.kt
class ApiKeyInterceptor @Inject constructor() : Interceptor {
    override fun intercept(chain: Interceptor.Chain): Response {
        val url = chain.request().url.newBuilder()
            .addQueryParameter("appid", BuildConfig.WEATHER_API_KEY)
            .build()
        return chain.proceed(chain.request().newBuilder().url(url).build())
    }
}

Из собранного APK ключ всё равно можно извлечь. Для тестового этого достаточно, но одна строка в README «в продакшене запросы шли бы через свой бэкенд» покажет, что вы это понимаете.

Желательно поправитьP2-1

В релизной сборке логируется тело запросов вместе с ключом

Где
di/NetworkModule.kt:21
Время
~10 мин

HttpLoggingInterceptor всегда включён на уровне BODY. В релизе адрес запроса с ключом и ответы попадут в Logcat.

Было · di/NetworkModule.kt
@Provides @Singleton
fun okHttp(apiKey: ApiKeyInterceptor): OkHttpClient = OkHttpClient.Builder()
    .addInterceptor(apiKey)
    .addInterceptor(HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.BODY })
    .build()
Стало · di/NetworkModule.kt
@Provides @Singleton
fun okHttp(apiKey: ApiKeyInterceptor): OkHttpClient = OkHttpClient.Builder()
    .addInterceptor(apiKey)
    .apply {
        if (BuildConfig.DEBUG) addInterceptor(HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.BASIC })
    }
    .connectTimeout(10, TimeUnit.SECONDS)
    .readTimeout(15, TimeUnit.SECONDS)
    .build()

Архитектура и данные

Исправить обязательноP1-2

Без сети экран пустой, хотя данные лежат в кэше

Где
data/WeatherRepository.kt:31–48
Время
~1,5 ч

Что не так. Репозиторий сначала идёт в сеть. Если запрос упал, он возвращает пустой список, а кэш читает только после удачного запроса. Требование «офлайн-кэш» выполнено формально: данные сохраняются, но не показываются.

Ещё одна проблема здесь же. catch (e: Exception) ловит и CancellationException. Если пользователь ушёл с экрана, корутина не отменится как положено.

Было · data/WeatherRepository.kt
suspend fun getForecast(city: String): List<DayForecast> {
    return try {
        val dto = api.forecast(city)
        dao.insert(dto.toEntities(city))
        dao.getForecast(city).map { it.toDomain() }
    } catch (e: Exception) {
        emptyList()
    }
}

Как исправить. Разделите чтение и обновление. Экран подписан на базу, сеть только обновляет её. Ловите конкретные ошибки сети, а не все подряд:

Стало · data/WeatherRepository.kt
fun forecast(city: String): Flow<List<DayForecast>> =
    dao.observeForecast(city).map { rows -> rows.map { it.toDomain() } }

suspend fun refresh(city: String): RefreshResult = try {
    val dto = api.forecast(city)
    dao.replaceForecast(city, dto.toEntities(city, fetchedAt = clock.now()))
    RefreshResult.Ok
} catch (e: IOException) {
    RefreshResult.Offline
} catch (e: HttpException) {
    RefreshResult.Error(e.code())
}

replaceForecast сделайте @Transaction: удалить старый прогноз города и вставить новый. Тогда экран не мигнёт пустым списком между двумя операциями.

Желательно поправитьP2-2

DTO из сети доходит до экрана

Где
ui/forecast/ForecastScreen.kt:40
Время
~40 мин

Composable-функции принимают ForecastDto.Item и читают поля вроде main.temp_max и dt_txt. Любое изменение API сломает UI, а превью и тесты приходится собирать из сетевых классов.

Было · ui/forecast/ForecastScreen.kt
@Composable
fun ForecastRow(item: ForecastDto.Item) {
    Text(item.dt_txt.substringBefore(' '))
    Text("${item.main.temp_min.roundToInt()}° / ${item.main.temp_max.roundToInt()}°")
}
Стало · domain/DayForecast.kt
data class DayForecast(
    val date: LocalDate,
    val minTemp: Int,
    val maxTemp: Int,
    val condition: Condition,
    val windMs: Double,
)

Маппинг ForecastDto → Entity → DayForecast держите в слое data. Экран знает только DayForecast.

Корутины и состояние экрана

Исправить обязательноP1-3

Ответы поиска гоняются: может показаться не тот город

Где
ui/search/SearchViewModel.kt:22–30
Время
~40 мин

Что не так. Каждый введённый символ запускает новый запрос, и предыдущие не отменяются. Ответы приходят в произвольном порядке. Ответ на «Мос» может прийти позже ответа на «Москва» и затереть его. Заодно лишние запросы расходуют лимит API.

Было · ui/search/SearchViewModel.kt
fun onQueryChange(query: String) {
    _query.value = query
    viewModelScope.launch {
        _results.value = repository.searchCities(query)
    }
}

Как исправить. Опишите поиск потоком: пауза 300 мс, без повторов, и flatMapLatest, который отменяет устаревший запрос:

Стало · ui/search/SearchViewModel.kt
private val _query = MutableStateFlow("")
val query: StateFlow<String> = _query.asStateFlow()

@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
val results: StateFlow<SearchState> = _query
    .debounce(300)
    .map { it.trim() }
    .distinctUntilChanged()
    .flatMapLatest { q ->
        if (q.length < 2) flowOf(SearchState.Idle)
        else flow {
            emit(SearchState.Loading)
            emit(repository.searchCities(q).toSearchState())
        }
    }
    .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SearchState.Idle)

fun onQueryChange(value: String) { _query.value = value }
Исправить обязательноP1-4

После поворота экрана пропадает введённый город

Где
ui/search/SearchScreen.kt:18
Время
~15 мин

Текст поиска хранится в remember { mutableStateOf("") }. При повороте экрана он сбрасывается, а результаты во ViewModel остаются — экран рассинхронизируется. Повернуть телефон — стандартная проверка.

Было · ui/search/SearchScreen.kt
@Composable
fun SearchScreen(vm: SearchViewModel = hiltViewModel()) {
    var query by remember { mutableStateOf("") }
    OutlinedTextField(
        value = query,
        onValueChange = { query = it; vm.onQueryChange(it) },
    )
    // …
}
Стало · ui/search/SearchScreen.kt
@Composable
fun SearchScreen(vm: SearchViewModel = hiltViewModel()) {
    val query by vm.query.collectAsStateWithLifecycle()
    val state by vm.results.collectAsStateWithLifecycle()
    SearchContent(query = query, state = state, onQueryChange = vm::onQueryChange)
}

Один источник правды для запроса — ViewModel из P1-3. collectAsStateWithLifecycle перестаёт собирать поток, когда приложение свёрнуто.

Желательно поправитьP2-3

Ошибка без выхода: нет повтора и обновления

Где
ui/forecast/ForecastScreen.kt:58
Время
~30 мин

При ошибке экран показывает одно слово «Ошибка». Добавьте кнопку «Повторить» и pull-to-refresh, которые вызывают refresh() из P1-2. Тексты ошибок сделайте разными для «нет сети» и «город не найден».

Было · ui/forecast/ForecastScreen.kt
when (val state = uiState) {
    is ForecastUiState.Loading -> CircularProgressIndicator()
    is ForecastUiState.Error -> Text("Ошибка")
    is ForecastUiState.Ready -> ForecastList(state.days)
}
Стало · ui/forecast/ForecastScreen.kt
is ForecastUiState.Error -> ErrorState(
    message = stringResource(if (state.offline) R.string.error_offline else R.string.error_city_not_found),
    onRetry = viewModel::refresh,
)
is ForecastUiState.Ready -> PullToRefreshBox(isRefreshing = state.refreshing, onRefresh = viewModel::refresh) {
    ForecastList(state.days)
}

Тесты

Исправить обязательноP1-5

Тесты ViewModel ходят в сеть и ждут Thread.sleep

Где
app/src/test/…/ForecastViewModelTest.kt
Время
~1 ч

Что не так. Тест создаёт настоящий Retrofit и ждёт две секунды. Без интернета или при исчерпанном лимите API он падает, а при медленной сети — через раз.

Почему это важно. Тесты прямо указаны в требованиях. Проверяющий запустит ./gradlew test, и красный тест заметно испортит впечатление.

Было · ForecastViewModelTest.kt
@Test
fun loadsForecast() {
    val vm = ForecastViewModel(WeatherRepository(RetrofitModule.api(), FakeDao()))
    vm.load("Moscow")
    Thread.sleep(2000)
    assertTrue(vm.state.value.days.isNotEmpty())
}

Как исправить. Выделите интерфейс репозитория, в тестах используйте фейк. Время управляйте через тестовый диспетчер: правило MainDispatcherRule и runTest.

Стало · ForecastViewModelTest.kt
class ForecastViewModelTest {
    @get:Rule val mainRule = MainDispatcherRule()
    private val repo = FakeWeatherRepository()

    @Test
    fun `shows cached forecast and offline banner when refresh fails`() = runTest {
        repo.cached = listOf(day(maxTemp = 12))
        repo.refreshResult = RefreshResult.Offline
        val vm = ForecastViewModel(repo)

        vm.selectCity("Москва")
        advanceUntilIdle()

        val state = vm.state.value
        assertEquals(12, state.days.first().maxTemp)
        assertTrue(state.isOffline)
    }
}

Хватит трёх тестов: успешная загрузка, офлайн с кэшем и ошибка без кэша. Они проверяют ровно то, что просили в задании.

Compose и качество кода

Желательно поправитьP2-4

Даты форматируются при каждой перерисовке

Где
ui/forecast/ForecastRow.kt:15
Время
~20 мин

SimpleDateFormat создаётся прямо в composable-функции и парсит строку dt_txt при каждой рекомпозиции. Форматируйте один раз при маппинге в DayForecast, через java.time:

Было · ui/forecast/ForecastRow.kt
@Composable
fun ForecastRow(item: ForecastDto.Item) {
    val parsed = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()).parse(item.dt_txt)
    Text(SimpleDateFormat("EEE, d MMM", Locale("ru")).format(parsed!!))
}
Стало · data/mapper/ForecastMapper.kt
private val dayFormatter = DateTimeFormatter.ofPattern("EEE, d MMM", Locale("ru"))

fun LocalDate.toDayLabel(): String = format(dayFormatter)
Желательно поправитьP2-5

У списка прогноза нет ключей

Где
ui/forecast/ForecastScreen.kt:44
Время
~5 мин

Без key Compose сопоставляет строки по позиции. При обновлении данных строки перерисовываются целиком, а состояние внутри них может «переехать» на соседнюю.

Было · ui/forecast/ForecastScreen.kt
LazyColumn {
    items(days) { day -> ForecastRow(day) }
}
Стало · ui/forecast/ForecastScreen.kt
LazyColumn {
    items(days, key = { it.date.toEpochDay() }) { day -> ForecastRow(day) }
}
Желательно поправитьP2-6

Тексты и единицы измерения прямо в коде

Где
ui/forecast/CurrentWeatherCard.kt, ui/forecast/ForecastRow.kt
Время
~30 мин

«Ветер», «м/с», «°C» записаны строками в коде. Вынесите их в strings.xml и stringResource(), а для «1 день / 2 дня / 5 дней» используйте pluralStringResource. Мелочь, но проверяющие на неё смотрят.

Было · ui/forecast/CurrentWeatherCard.kt
Text("Ветер ${weather.windMs} м/с")
Text("${weather.temp}°C")
Стало · ui/forecast/CurrentWeatherCard.kt
Text(stringResource(R.string.wind_speed, weather.windMs))
Text(stringResource(R.string.temperature_celsius, weather.temp))
Стало · res/values/strings.xml
<string name="wind_speed">Ветер %1$.1f м/с</string>
<string name="temperature_celsius">%1$d °C</string>
На будущееP3-1

Линтер и CI в репозитории

Подключите detekt и ktlint и простой GitHub Actions, который собирает проект и запускает тесты на каждый push. Зелёная галочка у коммитов производит хорошее впечатление ещё до открытия кода.

На будущееP3-2

Один UI-тест главного сценария

Тест на createComposeRule «ввёл город → увидел прогноз» с фейковым репозиторием. Это показывает, что вы умеете тестировать не только ViewModel.

На будущееP3-3

kotlinx.serialization вместо Gson

Gson молча оставляет null в не-nullable полях Kotlin, и такие ошибки всплывают далеко от места причины. kotlinx.serialization проверяет модель при разборе ответа.

Что дальше

  1. P1-1 Ключ API: отозвать, вынести в local.properties — ~20 мин
  2. P1-2 База как единственный источник данных — ~1,5 ч
  3. P1-3 и P1-4 Поиск: flatMapLatest, запрос во ViewModel — ~1 ч
  4. P1-5 Тесты на фейковом репозитории — ~1 ч
  5. P2-2 и P2-1 — если останется время, ~1 ч

Вопросы по отчёту — 1 раунд в течение 7 дней, пишите в тот же чат. Доступ к репозиторию можно отозвать уже сейчас.

Что упомянуть в сопроводительном письме: структуру по слоям с Hilt и офлайн-режим с базой как единственным источником данных — после исправления P1-2 это сильный пункт.