Мета та підготовка
Створити екран, який завантажує JSON із відкритого навчального API, показує Loading, Content і Error та дозволяє повторити запит. Потрібно знати ViewModel, coroutine, suspend і правила валідації зовнішніх даних. Демонстрація читає одну задачу JSONPlaceholder; основне завдання використовує власний екран і набір даних.
Створіть Empty Activity проєкт ApiLab з package com.example.apilab, minSdk 24 або вище. У app dependencies додайте implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.11.0") та implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.11.0"). Залиште Compose BOM і решту узгоджених версій шаблону; виконайте Gradle Sync. Для цих бібліотек потрібний сумісний сучасний compileSdk, зазначений Gradle metadata.
У manifest до application додайте <uses-permission android:name="android.permission.INTERNET" />. Не додавайте camera чи location permissions. INTERNET не потребує runtime діалогу. Використовуємо HTTPS із типовою перевіркою сертифікатів. Налаштування usesCleartextTraffic=true для цієї роботи не потрібне.
Теоретичний мінімум
HttpURLConnection є блокувальним I/O, тому виконується в Dispatchers.IO. ViewModelScope координує запуск і скасування. Cancellation не гарантує миттєвого припинення блокувального socket read, тому встановлено timeouts. UI state оновлюється після повернення до main. Повторні натискання під час Loading заборонені, щоб не створювати змагання відповідей.
Повний MainActivity.kt
package com.example.apilab
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.json.JSONObject
import java.net.HttpURLConnection
import java.net.URL
sealed interface ApiState {
data object Loading : ApiState
data class Content(val title: String) : ApiState
data class Error(val message: String) : ApiState
}
class TodoRepository {
suspend fun loadTitle(): String = withContext(Dispatchers.IO) {
val connection = URL("https://jsonplaceholder.typicode.com/todos/1")
.openConnection() as HttpURLConnection
try {
connection.connectTimeout = 10000
connection.readTimeout = 10000
connection.requestMethod = "GET"
if (connection.responseCode != 200) error("Unexpected HTTP status")
val text = connection.inputStream.bufferedReader().use { it.readText() }
val json = JSONObject(text)
require(json.has("title") && !json.isNull("title"))
json.getString("title").also { require(it.isNotBlank()) }
} finally { connection.disconnect() }
}
}
class TodoViewModel : ViewModel() {
private val repository = TodoRepository()
var state by mutableStateOf<ApiState>(ApiState.Loading)
private set
init { load() }
fun load() {
viewModelScope.launch {
state = ApiState.Loading
try { state = ApiState.Content(repository.loadTitle()) }
catch (cancelled: CancellationException) { throw cancelled }
catch (error: Exception) { state = ApiState.Error("Перевірте мережу й повторіть") }
}
}
}
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent { MaterialTheme { Surface { ApiScreen() } } }
}
}
@Composable
fun ApiScreen(model: TodoViewModel = viewModel()) {
Column(Modifier.padding(24.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
when (val state = model.state) {
ApiState.Loading -> Text("Завантаження…")
is ApiState.Content -> Text(state.title)
is ApiState.Error -> Text(state.message)
}
Button(onClick = model::load, enabled = model.state !is ApiState.Loading) {
Text("Оновити")
}
}
}
Після Run очікується title задачі або зрозумілий Error. API є зовнішнім сервісом, тому конкретний текст і доступність можуть змінюватися. Use закриває reader, finally — connection. ViewModel створюється через підтримуваний owner і переживає звичайний поворот у своєму scope; пряме створення нового instance у UI тілі змінило б цю гарантію.
Покрокове виконання
Перевірте JSON endpoint у браузері й структуру поля title. Запустіть застосунок із мережею, поверніть AVD та перевірте стан. Вимкніть мережу, натисніть оновлення й дочекайтеся timeout/error. Увімкніть мережу та повторіть. Запишіть початковий стан, дію, очікування й фактичний результат. Не вимикайте TLS перевірку для обходу відмови.
Самостійне завдання
Реалізуйте список задач із endpoint /todos або інший документований відкритий API без секретів. Потрібні перевірка status, JSON structure, модель із ID, порожній стан і повтор. Основну JSON обробку списку й UI створіть самостійно. Додайте fake repository для успіху та відмови й поясніть, як відокремити його від ViewModel.
Типові помилки, результат і запитання
NetworkOnMainThreadException вказує на неправильний dispatcher. Відсутній INTERNET permission блокує доступ. Загальний catch без окремого CancellationException порушує скасування. Зберігати весь exception як UI повідомлення небезпечно й незручно. Подайте source project, схему відповідальностей і таблицю мережевих сценаріїв. Чому suspend не замінює Dispatchers.IO? Що дають timeouts? Чим успішний порожній список відрізняється від Error?
Дослідження станів мережевого екрана
Запустіть проєкт з доступною мережею, перевірте Loading і кінцевий заголовок. Потім вимкніть мережу емулятора чи пристрою та повторіть завантаження. Очікується Error після відмови або таймауту, а кнопка повтору знову доступна. Поверніть мережу й перевірте успішне відновлення без перезапуску застосунку.
Для некоректного JSON використайте окремий контрольований fake repository у власній роботі. Він може повернути порожній title або завершитися визначеним винятком. Це дозволяє відтворити стан незалежно від доступності зовнішнього API. Не змінюйте справжній сервер і не вимикайте перевірку TLS заради демонстрації помилки.
Перевірте поворот екрана під час Loading. ViewModel зберігає операцію в межах відповідного owner, а UI повторно читає її стан. Якщо створювати нову ViewModel вручну всередині composable, ця гарантія може бути втрачена; використовуйте показаний механізм viewModel(). Окремо поясніть, що завершення процесу потребує іншої політики відновлення.
Для індивідуального списку завдань визначте Success з порожнім списком, частково заповненими даними й помилкою формату. Стан UI має пояснювати кожен випадок. У звіті додайте manifest-дозвіл, використані залежності, таблицю станів та фактичний журнал перевірок. Зовнішній навчальний API не гарантує доступність, тому відтворювані fake-сценарії потрібні для перевірки власної логіки. Вони не містять готового рішення основного завдання.
Окремий контрольний сценарій
Для кожної відмови перевірте завершення індикатора Loading. Якщо операція перехоплена без оновлення стану, користувач може залишитися на нескінченному очікуванні. Власний fake repository повинен дозволяти контрольовано отримати Success, Error та затриманий результат. Для затримки використовуйте корутинний delay, а не блокування головного потоку. Зафіксуйте дію повтору й переконайтеся, що вона викликає саме новий запит. Окремо поясніть політику для натискання під час вже активного запиту та її відображення кнопкою.
Схема процесу
Відображає стан і надсилає refresh.
Самоперевірка
Як обґрунтувати відповідь своїми словами?
Підсумок
Мережевий Android сценарій потребує явних відповідальностей, permissions і станів. Blocking I/O виконується поза main із timeouts. TLS перевірка, cancellation та політика повторення зберігають визначений контракт.