- Write a
suspendnetwork request with a Retrofit interface and turn JSON into a data class - Launch a request in
viewModelScopeand show loading, success and error states - Create an entity, a DAO and a database class in Room
- Store small settings in Preferences DataStore
Murad opens Educora on the metro, and there is no internet. A good app does not show an empty screen at that moment: the lessons were saved on the phone beforehand, the dark-theme choice is remembered, and when the internet comes back new lessons quietly load from the server. This takes three tools: Retrofit or Ktor for the network, a Room database for lists, and DataStore for settings.
Networking: Retrofit and Ktor
First add the internet permission to AndroidManifest.xml: <uses-permission android:name="android.permission.INTERNET" />. Retrofit is the most widely used HTTP library on Android: you just write an interface, and the library generates the request code. kotlinx.serialization turns JSON into Kotlin objects. The example below loads a list of to-dos from JSONPlaceholder, a public service for testing.
@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 quietly skips extra fields in the JSON (such as userId). baseUrl must end with /.Ktor is the alternative made by JetBrains for Kotlin; it also works in Kotlin Multiplatform, so the same networking code can be shared between Android and iOS. In Ktor a request looks like this: client.get("https://jsonplaceholder.typicode.com/todos").body<List<Todo>>() — where client is an HttpClient set up with the ContentNegotiation plugin and 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 field is skipped because the class does not have it.Coroutines in the ViewModel
A network request can take seconds, so we launch it as a coroutine in the ViewModel's **viewModelScope**. When the user closes the screen, the ViewModel is cleared and every coroutine in this scope is cancelled automatically — no wasted work and no memory leaks. We describe the result with a sealed state you already know from earlier lessons.
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): a spinning indicator, the list, or an error message with a “Try again” button.@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") }
}
}
}Note that TodosViewModel takes an api parameter in its constructor, so the plain viewModel() cannot create it — you either write a small factory (ViewModelProvider.Factory) or, as in real projects, use a dependency injection library such as Hilt or Koin. There is usually one more layer between the ViewModel and the network — a repository (TodoRepository): it hides where the data comes from (the server, the database, a cache), and the ViewModel talks only to it.
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: a database on the device
Room is the official library built on top of SQLite. It has three parts: an Entity — a data class that is a row of a table; a DAO — an interface that describes SQL queries as functions; and the Database — an abstract class that ties them together. The code is generated at compile time by the KSP plugin, and SQL mistakes are caught right then.
@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(). A query that returns a Flow emits a new list every time the table changes.DataStore: for settings
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 is declared once, at the top level of a file; creating a second instance for the same file causes an error.| What to store | Where |
|---|---|
| theme, language, notification switches | DataStore |
| lessons, results, lists that need searching and sorting | Room |
| images and large files | the app's files folder |
| the account and progress that must match on every device | the server |
- 1Open App Inspection
Run the app on an emulator and choose
View › Tool Windows › App Inspection. - 2Look at the database
In the
Database Inspectortab, openeducora.dband thelessonstable. The table updates live, and you can also write and test an SQL query there. - 3Watch the requests
In the
Network Inspectortab you will see every HTTP request, how long it took and the JSON the server returned.
Cmd+Shift+F)Ctrl+Shift+FCmd+Shift+Return)Ctrl+Shift+EnterCmd+D)Ctrl+DCmd+/)Ctrl+/Key points
- Networking needs the
INTERNETpermission in the manifest; Retrofit generates request code fromsuspendfunctions in an interface. - Requests run inside
viewModelScope.launch; they are cancelled automatically when the screen closes. - Describe the result with a
Loading / Success / Errorsealed state and catch errors withtry/catch. - Room: Entity + DAO + Database; a query that returns
Flowupdates the screen by itself when the table changes. - Small settings live in DataStore; the local database is the screen's single source of truth.
Check yourself
10 questions. Every correct answer earns XP.