从本地数据库到远程 API,让你的 App 拥有真正的"灵魂" -- 数据
追踪数据流,每条线索不放过
真相永远只有一个 -- 数据的流向也一样。一条数据从 API 出发,途经 Repository,在 ViewModel 的案件档案中汇总,最终呈堂到 UI 的法庭上。每个环节都是一条线索,每个线索都值得追踪。今天,我们就像侦探一样,顺着数据流向一路追查,从 ViewModel 的笔记本、Room 的证物柜,到 Retrofit 的情报网络,把整条链路查个水落石出。
在前面的章节中,我们学会了用 Jetpack Compose 构建漂亮的 UI。但如果你试过旋转屏幕,你就会发现一个关键线索被遗漏了 -- 数据不见了。计数器归零、表单清空、列表消失。这就像侦探在办案时,笔记本随手一丢,所有追踪记录全部丢失。原因是 Android 系统在屏幕旋转时会销毁并重建 Activity,而你把案件档案(数据)放在了一个随时可能被销毁的地方。
Android 的 Activity 和 Composable 函数有个致命缺陷:它们像临时出场的证人,随时可能被系统"请走"。常见的触发场景包括:
如果把数据直接放在这些组件中,每次重建都意味着案件档案丢失。我们需要一个比证人更可靠的地方来保管案件资料 -- 这就是 ViewModel,侦探的专属笔记本。
ViewModel 的生命周期与 Activity 截然不同。追踪它的存活轨迹你会发现:当 Activity 因为配置变更(如旋转屏幕)而重建时,ViewModel 不会被销毁 -- 它会一直存活,直到 Activity 真正结束(用户按返回键退出,或调用 finish())。
把 Activity 想象成办案的"外勤侦探",配置变更时外勤人员轮换了,但 ViewModel 是"案件档案室",无论外勤怎么换,案件档案始终完好保存。只有当案件真正结案(Activity 被 finish)时,档案才会销毁。
首先,在 build.gradle.kts 中添加必要的依赖 -- 就像侦探出发前先准备好装备:
// build.gradle.kts (Module)
dependencies {
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.0")
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.0")
}
然后建立你的第一个案件档案室 -- ViewModel 类:
import androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
class CounterViewModel : ViewModel() {
// 机密档案,只有档案室内部可以修改
private val _count = MutableStateFlow(0)
// 对外公开的只读汇报,UI 层只能查阅,不能篡改
val count: StateFlow<Int> = _count.asStateFlow()
fun increment() {
_count.value++
}
fun decrement() {
_count.value--
}
fun reset() {
_count.value = 0
}
}
在上面的代码中,我们使用了 StateFlow 来管理状态。你可以把它理解为一条从档案室到法庭(UI)的实时汇报通道。这是 Kotlin 协程库提供的一个特殊 Flow,非常适合追踪数据流向:
MutableStateFlow:可变的情报通道,ViewModel 内部用来更新案件进展StateFlow:只读的汇报通道,暴露给 UI 层观察这是 Android 侦探界的一个行规:_count 是机密的可变档案,count 是对外的只读汇报。这种 "backing property" 模式确保了只有 ViewModel 这个"档案管理员"能修改数据,UI 层只能读取和观察,从而保证了单向数据流 -- 情报只从源头流向终端,绝不允许逆流篡改。
档案室建好了,现在要在法庭(Compose UI)上接收汇报。让我们把两者连接起来:
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.lifecycle.viewmodel.compose.viewModel
@Composable
fun CounterScreen(
viewModel: CounterViewModel = viewModel()
) {
// 接收档案室的实时汇报,转换为 Compose 可感知的状态
val count by viewModel.count.collectAsState()
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
text = "计数: $count",
style = MaterialTheme.typography.headlineLarge
)
Spacer(modifier = Modifier.height(16.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = { viewModel.decrement() }) {
Text("-")
}
Button(onClick = { viewModel.reset() }) {
Text("重置")
}
Button(onclick = { viewModel.increment() }) {
Text("+")
}
}
}
}
追踪要点:
viewModel():Compose 提供的函数,自动创建或获取已有的 ViewModel 实例 -- 相当于自动调配档案室collectAsState():将 StateFlow 转换为 Compose 的 State,一旦档案更新,法庭自动收到汇报ViewModel 解决了"外勤轮换"时的档案保全问题,但如果整个警局关门(App 被关闭)再开门呢?内存中的案件记录就真的消失了。要让证据永久留存,我们需要一个证物柜 -- 数据库。
Room 是 Google 官方打造的 Android 证物柜方案。它本质上是 SQLite 之上的一个抽象层,但比直接操作 SQLite 可靠得多,就像用专业的证物管理系统取代了手写标签:
// build.gradle.kts (Module)
dependencies {
val roomVersion = "2.6.1"
implementation("androidx.room:room-runtime:$roomVersion")
implementation("androidx.room:room-ktx:$roomVersion") // 协程支持
ksp("androidx.room:room-compiler:$roomVersion") // 注解处理器
}
Room 使用注解处理器来生成代码,你需要在项目中启用 KSP(Kotlin Symbol Processing)插件。在项目级 build.gradle.kts 中添加 id("com.google.devtools.ksp") version "...",并在模块级文件中应用 id("com.google.devtools.ksp")。装备不齐全,侦探可出不了任务。
Room 的证物管理体系围绕三个核心角色展开,追踪每个角色的职责非常关键:
| 组件 | 注解 | 在案件中的角色 |
|---|---|---|
| 实体 (Entity) | @Entity |
证物本身 -- 定义每件证据的结构,每条记录是一件证物 |
| 数据访问对象 (DAO) | @Dao |
证物管理员 -- 负责证物的存入、取出、更新和销毁 |
| 数据库 (Database) | @Database |
证物柜本体 -- 数据库配置,是进入证物柜的唯一入口 |
让我们用一个待办事项(Todo)案件来理解这三大组件。
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "todos")
data class Todo(
@PrimaryKey(autoGenerate = true)
val id: Int = 0,
val title: String,
val description: String = "",
val isCompleted: Boolean = false,
val createdAt: Long = System.currentTimeMillis()
)
追踪解读:
@Entity:给证物贴上标签,告诉 Room 这个 data class 对应证物柜中的一个分类tableName:指定分类名(表名),不写默认用类名@PrimaryKey(autoGenerate = true):证物编号,自动递增,确保每件证物有唯一标识import androidx.room.*
import kotlinx.coroutines.flow.Flow
@Dao
interface TodoDao {
// 调取全部证物,返回 Flow 实现实时追踪
@Query("SELECT * FROM todos ORDER BY createdAt DESC")
fun getAllTodos(): Flow<List<Todo>>
// 根据编号精确定位单件证物
@Query("SELECT * FROM todos WHERE id = :todoId")
suspend fun getTodoById(todoId: Int): Todo?
// 将一件新证物存入柜中
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertTodo(todo: Todo)
// 更新已有证物的记录
@Update
suspend fun updateTodo(todo: Todo)
// 销毁一件证物
@Delete
suspend fun deleteTodo(todo: Todo)
// 追踪尚未结案的证物数量
@Query("SELECT COUNT(*) FROM todos WHERE isCompleted = 0")
fun getActiveCount(): Flow<Int>
}
关键线索:
suspend fun:增删改操作使用挂起函数,在后台协程中默默取证,不阻塞主线程Flow<List<Todo>>:查询返回 Flow -- 证物柜中有任何变动,追踪系统自动汇报新情况,UI 自动更新import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
@Database(entities = [Todo::class], version = 1)
abstract class TodoDatabase : RoomDatabase() {
abstract fun todoDao(): TodoDao
companion object {
@Volatile
private var INSTANCE: TodoDatabase? = null
fun getDatabase(context: Context): TodoDatabase {
return INSTANCE ?: synchronized(this) {
val instance = Room.databaseBuilder(
context.applicationContext,
TodoDatabase::class.java,
"todo_database"
).build()
INSTANCE = instance
instance
}
}
}
}
这里使用了单例模式(companion object + synchronized),确保整栋大楼只有一个证物柜 -- 不能让证据散落在多个地方。
Room 与 Flow 的联动是整条追踪链路中最精妙的环节。当 DAO 的查询方法返回 Flow 时,Room 就像一个永不下线的监控摄像头,实时监视证物柜的一切变动:
// 在 ViewModel(案件档案室)中
class TodoViewModel(
private val dao: TodoDao
) : ViewModel() {
// 自动追踪证物柜变化,有新证据入库时 UI 自动刷新
val allTodos: StateFlow<List<Todo>> = dao.getAllTodos()
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = emptyList()
)
fun addTodo(title: String) {
viewModelScope.launch {
dao.insertTodo(Todo(title = title))
}
}
fun toggleComplete(todo: Todo) {
viewModelScope.launch {
dao.updateTodo(todo.copy(isCompleted = !todo.isCompleted))
}
}
}
追踪这里的数据流向你会发现一个精妙之处:当你调用 addTodo() 存入一条新证据后,你不需要手动刷新列表。因为 allTodos 是一个 Flow,Room 会自动检测到证物柜的变动,并通过追踪通道发出新的证物清单。Compose 收到汇报后自动重组 UI。整个过程完全是响应式的 -- 证物一入库,所有追踪者同步收到通知。
本地证物柜搞定了,但要破解真正的大案,光靠内部证据远远不够。社交动态、新闻资讯、天气预报 -- 这些情报都来自外部世界。Retrofit 就是我们的情报网络,它是 Android 生态中最强大的线人系统,让从远程服务器获取情报变得像查阅本地档案一样简单。
Retrofit 由 Square 公司开发(他们还打造了 OkHttp、Moshi 等知名情报工具),是一个类型安全的 HTTP 客户端。它的核心思路是:你只需要定义一个接口来描述情报来源的地址和格式,Retrofit 会自动帮你完成联络线人、获取情报、解码密文的所有细节。
// build.gradle.kts (Module)
dependencies {
// Retrofit 核心 -- 情报网络基础设施
implementation("com.squareup.retrofit2:retrofit:2.11.0")
// JSON 转换器 -- 密文解码器(二选一)
// 方式一:Gson(简单,适合入门)
implementation("com.squareup.retrofit2:converter-gson:2.11.0")
// 方式二:kotlinx.serialization(Kotlin 原生,推荐)
// implementation("com.squareup.retrofit2:converter-kotlinx-serialization:2.11.0")
// implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3")
}
在 AndroidManifest.xml 中添加网络权限:
<uses-permission android:name="android.permission.INTERNET" />
没有这一行,你的情报网络一条线路都通不了!就像侦探出门办案忘了带警徽。
假设我们要联络一个天气情报来源,先定义情报的格式和联络方式:
// 情报格式(数据模型)
data class WeatherResponse(
val city: String,
val temperature: Double,
val description: String,
val humidity: Int
)
// 情报来源接口
interface WeatherApiService {
// GET 请求,{city} 是追踪目标参数
@GET("weather/{city}")
suspend fun getWeather(
@Path("city") city: String
): WeatherResponse
// GET 请求,带搜索条件
@GET("weather/search")
suspend fun searchWeather(
@Query("q") query: String,
@Query("units") units: String = "metric"
): List<WeatherResponse>
}
常用的 Retrofit 注解 -- 情报联络暗号对照表:
| 注解 | 作用 | 示例 |
|---|---|---|
@GET |
获取情报(GET 请求) | @GET("users/{id}") |
@POST |
提交情报(POST 请求) | @POST("users") |
@Path |
路径参数 | @Path("id") id: Int |
@Query |
查询参数 (?key=value) | @Query("page") page: Int |
@Body |
请求体 (JSON) | @Body user: User |
@Header |
请求头(身份凭证) | @Header("Token") token: String |
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
object RetrofitClient {
private const val BASE_URL = "https://api.example.com/"
private val retrofit: Retrofit = Retrofit.Builder()
.baseUrl(BASE_URL)
.addConverterFactory(GsonConverterFactory.create())
.build()
val weatherApi: WeatherApiService = retrofit.create(WeatherApiService::class.java)
}
object 关键字确保整个情报网络只有一个总部。GsonConverterFactory 是密文解码器,负责自动将 JSON 格式的情报翻译成 Kotlin 对象。
注意到 API 接口中的方法都用了 suspend 关键字。这意味着 Retrofit 会自动在后台执行情报收集任务,不会阻塞主线程 -- 就像派出便衣去接头,办公室里的工作照常进行:
class WeatherViewModel : ViewModel() {
private val _weather = MutableStateFlow<WeatherResponse?>(null)
val weather: StateFlow<WeatherResponse?> = _weather.asStateFlow()
private val _error = MutableStateFlow<String?>(null)
val error: StateFlow<String?> = _error.asStateFlow()
fun fetchWeather(city: String) {
viewModelScope.launch {
try {
val result = RetrofitClient.weatherApi.getWeather(city)
_weather.value = result
_error.value = null
} catch (e: Exception) {
_error.value = "情报获取失败: ${e.message}"
}
}
}
}
对比传统的回调方式,协程让异步的情报收集看起来像同步的档案查阅一样直观。一个 try/catch 就能处理线人失联(网络错误)的情况,不需要写 onSuccess/onFailure 这种复杂的回调链。这正是我们在第6章学习协程时说的"用同步的方式写异步代码"。
所有线索已经收集齐了,现在是破案的时刻。让我们把前面掌握的所有侦查手段串联起来,构建一个完整的新闻列表 App。这个案件会用到:ViewModel(案件档案室)管理状态、Retrofit(情报网络)请求远程数据、Compose(法庭展示)呈现 UI。把它想象成一次完整的案件侦办 -- 从收集情报,到整理归档,最后呈堂展示。
一个组织严密的侦查小组需要明确的分工。我们按职责分包:
com.example.newsapp/
├── model/ // 情报格式定义
│ └── Article.kt
├── data/ // 情报来源层(线人网络、情报汇总)
│ ├── NewsApiService.kt
│ └── NewsRepository.kt
├── viewmodel/ // 案件档案室(状态管理)
│ └── NewsViewModel.kt
└── ui/ // 法庭展示层(UI)
├── NewsScreen.kt
└── theme/
// model/Article.kt
data class Article(
val id: Int,
val title: String,
val summary: String,
val imageUrl: String,
val source: String,
val publishedAt: String,
val url: String
)
// 情报来源返回的包装信封
data class NewsResponse(
val status: String,
val totalResults: Int,
val articles: List<Article>
)
// data/NewsApiService.kt
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import retrofit2.http.GET
import retrofit2.http.Query
interface NewsApiService {
@GET("top-headlines")
suspend fun getTopHeadlines(
@Query("country") country: String = "cn",
@Query("apiKey") apiKey: String = "YOUR_API_KEY"
): NewsResponse
@GET("everything")
suspend fun searchNews(
@Query("q") query: String,
@Query("apiKey") apiKey: String = "YOUR_API_KEY"
): NewsResponse
companion object {
private const val BASE_URL = "https://newsapi.org/v2/"
fun create(): NewsApiService {
return Retrofit.Builder()
.baseUrl(BASE_URL)
.addConverterFactory(GsonConverterFactory.create())
.build()
.create(NewsApiService::class.java)
}
}
}
Repository 是整个侦查行动的情报汇总中心。案件档案室(ViewModel)只跟情报中心对接,不需要知道情报是从哪个线人、哪条渠道得来的:
// data/NewsRepository.kt
class NewsRepository(
private val apiService: NewsApiService
) {
suspend fun getTopHeadlines(): List<Article> {
val response = apiService.getTopHeadlines()
return response.articles
}
suspend fun searchNews(query: String): List<Article> {
val response = apiService.searchNews(query)
return response.articles
}
}
也许你会觉得 Repository 只是简单地转发了一下线人的情报,为什么要多设一个环节?追踪更深层的数据流向你就会明白:当案件变复杂后,Repository 可以同时对接网络线人(Retrofit)和本地证物柜(Room),实现"先展示证物柜里的存档、后台悄悄从线人处更新"的侦查策略。一层中间人,无限可能。
侦查行动有三种状态:侦查中、情报到手、行动失败。用 sealed class(在第5章学过)可以精确追踪这三种案件进展:
// viewmodel/UiState.kt
sealed class NewsUiState {
// 正在侦查中...
data object Loading : NewsUiState()
// 情报到手,携带案件线索列表
data class Success(
val articles: List<Article>
) : NewsUiState()
// 行动失败,携带失败原因
data class Error(
val message: String
) : NewsUiState()
}
使用 sealed class 的好处:在 when 表达式中,编译器会强制你处理所有状态。任何一种案件进展都不会被遗漏 -- 侦探最忌讳的就是漏掉线索。
// viewmodel/NewsViewModel.kt
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
class NewsViewModel : ViewModel() {
private val repository = NewsRepository(
apiService = NewsApiService.create()
)
private val _uiState = MutableStateFlow<NewsUiState>(NewsUiState.Loading)
val uiState: StateFlow<NewsUiState> = _uiState.asStateFlow()
init {
loadNews()
}
fun loadNews() {
viewModelScope.launch {
_uiState.value = NewsUiState.Loading
try {
val articles = repository.getTopHeadlines()
_uiState.value = NewsUiState.Success(articles)
} catch (e: Exception) {
_uiState.value = NewsUiState.Error(
message = e.message ?: "未知错误"
)
}
}
}
fun searchNews(query: String) {
viewModelScope.launch {
_uiState.value = NewsUiState.Loading
try {
val articles = repository.searchNews(query)
_uiState.value = NewsUiState.Success(articles)
} catch (e: Exception) {
_uiState.value = NewsUiState.Error(
message = e.message ?: "搜索失败"
)
}
}
}
}
// ui/NewsScreen.kt
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
@Composable
fun NewsScreen(
viewModel: NewsViewModel = viewModel()
) {
val uiState by viewModel.uiState.collectAsState()
Scaffold(
topBar = {
TopAppBar(
title = { Text("新闻头条") }
)
}
) { paddingValues ->
when (val state = uiState) {
// 侦查进行中:显示追踪指示器
is NewsUiState.Loading -> {
Box(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
// 情报到手:在法庭上展示所有线索
is NewsUiState.Success -> {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
items(state.articles) { article ->
NewsCard(article = article)
}
}
}
// 行动失败:汇报失败原因,提供重新侦查的机会
is NewsUiState.Error -> {
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
text = state.message,
color = MaterialTheme.colorScheme.error
)
Spacer(modifier = Modifier.height(16.dp))
Button(onClick = { viewModel.loadNews() }) {
Text("重试")
}
}
}
}
}
}
@Composable
fun NewsCard(article: Article) {
Card(
modifier = Modifier.fillMaxWidth(),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
) {
Column(
modifier = Modifier.padding(16.dp)
) {
// 案件标题
Text(
text = article.title,
style = MaterialTheme.typography.titleMedium,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.height(8.dp))
// 案件摘要
Text(
text = article.summary,
style = MaterialTheme.typography.bodyMedium,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(8.dp))
// 底部信息:情报来源 + 时间戳
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = article.source,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary
)
Text(
text = article.publishedAt,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.outline
)
}
}
}
}
现在让我们像真正的侦探一样,追踪数据在整个应用中的完整流向。每一步都是一条不可遗漏的线索:
init 块发出侦查指令 loadNews()Loading(侦查中)-> 法庭显示等待指示器Success(articles) 或 Error(message)// 完整数据流向追踪图
//
// [法庭/UI] [档案室/VM] [情报中心/Repo] [线人/API]
// | | | |
// | collectAsState | | |
// |<-- StateFlow ---| | |
// | |--- loadNews() -->| |
// | | |-- HTTP GET -->|
// | | |<-- JSON ------|
// | |<-- articles ----| |
// |<-- 重组 UI ------| | |
//
示例代码中的 "YOUR_API_KEY" 只是占位符。在实际项目中,绝对不要把 API Key 硬编码在源代码中 -- 这就像把警局的门禁密码写在大门上。推荐的做法是将其放在 local.properties 文件(已被 .gitignore 忽略)中,然后通过 BuildConfig 在编译时注入。
这个新闻 App 已经是一个完整的可运行案件了,但真正的侦探永远不会满足于此。你可以继续追踪以下方向:
本章我们追踪了 Android 应用中最核心的数据流向链路:用 ViewModel(案件档案室)管理 UI 状态并在人员轮换时保全档案;用 Room(证物柜)实现证据的永久保存;用 Retrofit(情报网络)从远程服务器收集情报。最后通过一个新闻列表 App,将整条侦查链路从头到尾串联起来。这个"ViewModel + Repository + Retrofit/Room"的架构模式,就是 Android 官方推荐的标准办案流程,也是几乎所有商业 App 都在使用的模式。掌握了这条数据流向,你就拥有了追踪任何案件的基本能力。真相只有一个 -- 数据的流向也一样。
为 CounterViewModel 添加一个 history 属性,用 StateFlow<List<Int>> 记录每次 count 变化后的值(追踪历史记录)。每当调用 increment()、decrement() 或 reset() 时,都将新的 count 值追加到历史列表中。
使用一个 MutableStateFlow<List<Int>> 作为 backing property。每次修改 _count 之后,将新值追加到历史列表中。注意 StateFlow 通过引用相等判断变化,所以更新列表时需要创建一个新的 List 实例(例如用 _history.value = _history.value + newValue)。
class CounterViewModel : ViewModel() {
private val _count = MutableStateFlow(0)
val count: StateFlow<Int> = _count.asStateFlow()
// 追踪历史记录
private val _history = MutableStateFlow<List<Int>>(listOf(0))
val history: StateFlow<List<Int>> = _history.asStateFlow()
private fun recordHistory() {
_history.value = _history.value + _count.value
}
fun increment() {
_count.value++
recordHistory()
}
fun decrement() {
_count.value--
recordHistory()
}
fun reset() {
_count.value = 0
recordHistory()
}
}
在 TodoDao 中添加一个新方法 searchTodos,接收一个关键词参数 query: String,返回 Flow<List<Todo>>,查找 title 或 description 中包含该关键词的所有待办事项,结果按创建时间降序排列。
在 Room 的 @Query 注解中使用 SQL 的 LIKE 操作符进行模糊匹配。通配符 % 需要在参数中拼接,而不是直接写在 SQL 字符串里。可以使用 '%' || :query || '%' 的写法在 SQL 层面拼接通配符。
@Dao
interface TodoDao {
// ... 其他方法保持不变 ...
// 根据关键词搜索证物
@Query("""
SELECT * FROM todos
WHERE title LIKE '%' || :query || '%'
OR description LIKE '%' || :query || '%'
ORDER BY createdAt DESC
""")
fun searchTodos(query: String): Flow<List<Todo>>
}
为 WeatherApiService 添加一个新的 POST 请求方法 reportWeather,用于向服务器提交一份天气观测报告。请求路径为 "weather/report",需要在请求头中携带一个名为 Authorization 的认证令牌,请求体是一个 WeatherReport 对象(包含 city: String、temperature: Double、notes: String 三个字段)。返回一个 ApiResult 对象(包含 success: Boolean 和 message: String)。
需要用到三个 Retrofit 注解:@POST 定义请求类型和路径,@Header 添加请求头参数,@Body 标记请求体。别忘了方法需要用 suspend 关键字修饰,以支持协程调用。先定义好 WeatherReport 和 ApiResult 两个数据类。
// 天气观测报告(提交给服务器的情报)
data class WeatherReport(
val city: String,
val temperature: Double,
val notes: String
)
// 服务器返回的操作结果
data class ApiResult(
val success: Boolean,
val message: String
)
interface WeatherApiService {
// ... 其他方法保持不变 ...
@POST("weather/report")
suspend fun reportWeather(
@Header("Authorization") token: String,
@Body report: WeatherReport
): ApiResult
}
为新闻 App 的 NewsViewModel 添加一个"收藏新闻"功能。要求:(1) 新增一个 _favorites 状态,类型为 MutableStateFlow<Set<Int>>,存储被收藏文章的 id 集合;(2) 实现 toggleFavorite(articleId: Int) 方法,切换收藏/取消收藏;(3) 提供一个 isFavorite(articleId: Int): Boolean 方法供 UI 判断某篇文章是否被收藏。
使用 Set<Int> 来存储收藏的文章 id,这样查找和去重都很高效。toggleFavorite 方法的核心逻辑是:如果 id 已经在集合中就移除,否则就添加。注意 StateFlow 基于引用比较来判断值是否变化,所以每次更新时需要创建新的 Set 实例。isFavorite 方法直接检查 _favorites.value 中是否包含该 id 即可。
class NewsViewModel : ViewModel() {
// ... repository、uiState 等保持不变 ...
// 收藏档案:存储被收藏文章的 id 集合
private val _favorites = MutableStateFlow<Set<Int>>(emptySet())
val favorites: StateFlow<Set<Int>> = _favorites.asStateFlow()
// 切换收藏状态
fun toggleFavorite(articleId: Int) {
val current = _favorites.value
_favorites.value = if (articleId in current) {
current - articleId // 取消收藏:从集合中移除
} else {
current + articleId // 加入收藏:添加到集合中
}
}
// 检查是否已收藏
fun isFavorite(articleId: Int): Boolean {
return articleId in _favorites.value
}
}