Перейти к содержанию
Educora
Продвинутый25 мин13 / 14

Данные: сеть, Room и DataStore

Загружай JSON с сервера через Retrofit или Ktor, выполняй запросы в корутинах во ViewModel, не замораживая экран, храни списки в базе данных Room, а настройки — в DataStore.

Проверь себя
В этом уроке ты узнаешь
  • Писать сетевой suspend-запрос через интерфейс Retrofit и превращать JSON в data-класс
  • Запускать запрос в viewModelScope и показывать состояния загрузки, успеха и ошибки
  • Создавать в Room сущность, DAO и класс базы данных
  • Хранить небольшие настройки в Preferences DataStore

Мурад открывает Educora в метро, а интернета нет. Хорошее приложение в этот момент не показывает пустой экран: уроки заранее сохранены на телефоне, выбор тёмной темы запомнен, а когда интернет вернётся, новые уроки тихо загрузятся с сервера. Для этого нужны три инструмента: Retrofit или Ktor для сети, база данных Room для списков и DataStore для настроек.

Сеть: Retrofit и Ktor

Сначала добавь в AndroidManifest.xml разрешение на интернет: <uses-permission android:name="android.permission.INTERNET" />. Retrofit — самая распространённая HTTP-библиотека в Android: ты пишешь только интерфейс, а код запроса библиотека создаёт сама. JSON в объекты Kotlin превращает kotlinx.serialization. Пример ниже загружает список задач с JSONPlaceholder — открытого сервиса для тестов.

Kotlin
@Serializable
data class Todo(val id: Int, val title: String, val completed: Boolean)

interface TodoApi {
    @GET("todos")
    suspend fun getTodos(): List<Todo>

    @GET("todos/{id}")
    suspend fun getTodo(@Path("id") id: Int): Todo
}

private val json = Json { ignoreUnknownKeys = true }

val todoApi: TodoApi = Retrofit.Builder()
    .baseUrl("https://jsonplaceholder.typicode.com/")
    .addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
    .build()
    .create(TodoApi::class.java)
ignoreUnknownKeys = true спокойно пропускает лишние поля JSON (например, userId). baseUrl обязательно должен заканчиваться на /.

Ktor — альтернатива от JetBrains, созданная для Kotlin; она работает и в Kotlin Multiplatform, так что один и тот же сетевой код можно разделить между Android и iOS. Запрос на Ktor выглядит так: client.get("https://jsonplaceholder.typicode.com/todos").body<List<Todo>>(), где client — это HttpClient, настроенный с плагином ContentNegotiation и json().

Kotlin
@Serializable
data class Lesson(val id: Int, val title: String, val minutes: Int)

fun main() {
    val lesson = Lesson(1, "Variables", 10)
    println(Json.encodeToString(lesson))

    val json = Json { ignoreUnknownKeys = true }
    val text = """{"id":2,"title":"Loops","minutes":15,"level":"beginner"}"""
    val loaded = json.decodeFromString<Lesson>(text)
    println(loaded)
    println("Total: ${lesson.minutes + loaded.minutes} min")
}
Ожидаемый результат
{"id":1,"title":"Variables","minutes":10}
Lesson(id=2, title=Loops, minutes=15)
Total: 25 min
Проверка без сети: kotlinx.serialization превращает объект в JSON и обратно. Поле level пропускается, потому что его нет в классе.

Корутины во ViewModel

Сетевой запрос может длиться секунды, поэтому запускаем его как корутину в **viewModelScope** — области ViewModel. Когда пользователь закрывает экран, ViewModel очищается, и все корутины в этой области автоматически отменяются — ни лишней работы, ни утечек памяти. Результат описываем знакомым по прошлым урокам sealed-состоянием.

Kotlin
sealed interface TodosUiState {
    data object Loading : TodosUiState
    data class Success(val todos: List<Todo>) : TodosUiState
    data class Error(val message: String) : TodosUiState
}

class TodosViewModel(private val api: TodoApi) : ViewModel() {
    private val _uiState = MutableStateFlow<TodosUiState>(TodosUiState.Loading)
    val uiState: StateFlow<TodosUiState> = _uiState.asStateFlow()

    init { load() }

    fun load() {
        viewModelScope.launch {
            _uiState.value = TodosUiState.Loading
            _uiState.value = try {
                TodosUiState.Success(api.getTodos())
            } catch (e: IOException) {
                TodosUiState.Error("No connection")
            } catch (e: HttpException) {
                TodosUiState.Error("Server error ${e.code()}")
            }
        }
    }
}
Экран показывает три случая через when (state): крутящийся индикатор, список или сообщение об ошибке с кнопкой «Try again».
Kotlin
@Composable
fun TodosScreen(viewModel: TodosViewModel) {
    val state by viewModel.uiState.collectAsStateWithLifecycle()

    when (val current = state) {
        TodosUiState.Loading -> CircularProgressIndicator()
        is TodosUiState.Success -> LazyColumn {
            items(current.todos, key = { it.id }) { todo -> Text(todo.title) }
        }
        is TodosUiState.Error -> Column {
            Text(current.message)
            Button(onClick = viewModel::load) { Text("Try again") }
        }
    }
}
На экране сначала крутится круг, затем появляется список задач; без интернета — текст «No connection» и кнопка «Try again».

Обрати внимание: TodosViewModel принимает в конструкторе параметр api, поэтому обычный viewModel() не сможет её создать — нужна либо небольшая фабрика (ViewModelProvider.Factory), либо, как в реальных проектах, библиотека внедрения зависимостей вроде Hilt или Koin. Обычно между ViewModel и сетью есть ещё один слой — репозиторий (TodoRepository): он скрывает, откуда берутся данные (сервер, база, кэш), а ViewModel общается только с ним.

Неверно: главный поток заблокирован
fun load() {
    // blocks the main thread until the server answers
    val todos = runBlocking { api.getTodos() }
    _uiState.value = TodosUiState.Success(todos)
}
Верно: корутина в viewModelScope
fun load() {
    viewModelScope.launch {
        _uiState.value = try {
            TodosUiState.Success(api.getTodos())
        } catch (e: IOException) {
            TodosUiState.Error("No connection")
        }
    }
}
Слева экран зависает до ответа сервера, и через несколько секунд система может показать окно «Application Not Responding» (ANR). К тому же слева ошибки не перехватываются.

Room: база данных на устройстве

Room — официальная библиотека поверх SQLite. Она состоит из трёх частей: Entity — data-класс, который является строкой таблицы; DAO — интерфейс, описывающий SQL-запросы как функции; Database — абстрактный класс, который их объединяет. Код генерируется при компиляции плагином KSP, и ошибки в SQL обнаруживаются уже тогда.

Kotlin
@Entity(tableName = "lessons")
data class LessonEntity(
    @PrimaryKey val id: Int,
    val title: String,
    val done: Boolean = false
)

@Dao
interface LessonDao {
    @Query("SELECT * FROM lessons ORDER BY id")
    fun observeAll(): Flow<List<LessonEntity>>

    @Upsert
    suspend fun upsertAll(lessons: List<LessonEntity>)

    @Query("UPDATE lessons SET done = 1 WHERE id = :id")
    suspend fun markDone(id: Int)
}

@Database(entities = [LessonEntity::class], version = 1)
abstract class AppDatabase : RoomDatabase() {
    abstract fun lessonDao(): LessonDao
}
База создаётся один раз: Room.databaseBuilder(context, AppDatabase::class.java, "educora.db").build(). Запрос, возвращающий Flow, присылает новый список при каждом изменении таблицы.

DataStore: для настроек

Kotlin
val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "settings")

private val DARK_THEME = booleanPreferencesKey("dark_theme")

class SettingsRepository(private val context: Context) {
    val darkTheme: Flow<Boolean> =
        context.dataStore.data.map { prefs -> prefs[DARK_THEME] ?: false }

    suspend fun setDarkTheme(enabled: Boolean) {
        context.dataStore.edit { prefs -> prefs[DARK_THEME] = enabled }
    }
}
preferencesDataStore объявляется один раз на верхнем уровне файла; второй экземпляр для того же файла вызовет ошибку.
Что хранитьГде
тема, язык, переключатели уведомленийDataStore
уроки, результаты, списки, которые нужно искать и сортироватьRoom
изображения и большие файлыпапка файлов приложения
аккаунт и прогресс, который должен совпадать на всех устройствахсервер
  1. 1
    Открой App Inspection

    Запусти приложение в эмуляторе и выбери View › Tool Windows › App Inspection.

  2. 2
    Посмотри базу

    На вкладке Database Inspector открой educora.db и таблицу lessons. Таблица обновляется в реальном времени, а ещё там можно написать и проверить SQL-запрос.

  3. 3
    Следи за запросами

    На вкладке Network Inspector видны все HTTP-запросы, их длительность и JSON, который вернул сервер.

искать текст во всём проекте (Mac: Cmd+Shift+F)Ctrl+Shift+F
завершить выражение: сам закрывает скобки (Mac: Cmd+Shift+Return)Ctrl+Shift+Enter
дублировать строку (Mac: Cmd+D)Ctrl+D
закомментировать или раскомментировать строку (Mac: Cmd+/)Ctrl+/

Главное

  • Для сети нужно разрешение INTERNET в манифесте; Retrofit создаёт код запросов из suspend-функций интерфейса.
  • Запросы выполняются внутри viewModelScope.launch и автоматически отменяются, когда экран закрывается.
  • Описывай результат sealed-состоянием Loading / Success / Error и перехватывай ошибки через try/catch.
  • Room: Entity + DAO + Database; запрос с Flow сам обновляет экран при изменении таблицы.
  • Небольшие настройки хранятся в DataStore; единым источником правды для экрана служит локальная база.

Проверь себя

Вопросов: 10. Каждый правильный ответ приносит XP.

1 / 10
Какое разрешение должно быть в манифесте, чтобы приложение выходило в интернет?