- Писать сетевой
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 — открытого сервиса для тестов.
@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().
@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 minlevel пропускается, потому что его нет в классе.Корутины во ViewModel
Сетевой запрос может длиться секунды, поэтому запускаем его как корутину в **viewModelScope** — области ViewModel. Когда пользователь закрывает экран, ViewModel очищается, и все корутины в этой области автоматически отменяются — ни лишней работы, ни утечек памяти. Результат описываем знакомым по прошлым урокам sealed-состоянием.
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».@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") }
}
}
}Обрати внимание: 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)
}fun load() {
viewModelScope.launch {
_uiState.value = try {
TodosUiState.Success(api.getTodos())
} catch (e: IOException) {
TodosUiState.Error("No connection")
}
}
}Room: база данных на устройстве
Room — официальная библиотека поверх SQLite. Она состоит из трёх частей: Entity — data-класс, который является строкой таблицы; DAO — интерфейс, описывающий SQL-запросы как функции; Database — абстрактный класс, который их объединяет. Код генерируется при компиляции плагином KSP, и ошибки в SQL обнаруживаются уже тогда.
@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: для настроек
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Открой App Inspection
Запусти приложение в эмуляторе и выбери
View › Tool Windows › App Inspection. - 2Посмотри базу
На вкладке
Database Inspectorоткройeducora.dbи таблицуlessons. Таблица обновляется в реальном времени, а ещё там можно написать и проверить SQL-запрос. - 3Следи за запросами
На вкладке
Network Inspectorвидны все HTTP-запросы, их длительность и JSON, который вернул сервер.
Cmd+Shift+F)Ctrl+Shift+FCmd+Shift+Return)Ctrl+Shift+EnterCmd+D)Ctrl+DCmd+/)Ctrl+/Главное
- Для сети нужно разрешение
INTERNETв манифесте; Retrofit создаёт код запросов изsuspend-функций интерфейса. - Запросы выполняются внутри
viewModelScope.launchи автоматически отменяются, когда экран закрывается. - Описывай результат sealed-состоянием
Loading / Success / Errorи перехватывай ошибки черезtry/catch. - Room: Entity + DAO + Database; запрос с
Flowсам обновляет экран при изменении таблицы. - Небольшие настройки хранятся в DataStore; единым источником правды для экрана служит локальная база.
Проверь себя
Вопросов: 10. Каждый правильный ответ приносит XP.