第9章:数据与网络

从本地数据库到远程 API,让你的 App 拥有真正的"灵魂" -- 数据

🔍

柯南

追踪数据流,每条线索不放过

真相永远只有一个 -- 数据的流向也一样。一条数据从 API 出发,途经 Repository,在 ViewModel 的案件档案中汇总,最终呈堂到 UI 的法庭上。每个环节都是一条线索,每个线索都值得追踪。今天,我们就像侦探一样,顺着数据流向一路追查,从 ViewModel 的笔记本、Room 的证物柜,到 Retrofit 的情报网络,把整条链路查个水落石出。

9.1 ViewModel 与状态管理

在前面的章节中,我们学会了用 Jetpack Compose 构建漂亮的 UI。但如果你试过旋转屏幕,你就会发现一个关键线索被遗漏了 -- 数据不见了。计数器归零、表单清空、列表消失。这就像侦探在办案时,笔记本随手一丢,所有追踪记录全部丢失。原因是 Android 系统在屏幕旋转时会销毁并重建 Activity,而你把案件档案(数据)放在了一个随时可能被销毁的地方。

为什么需要 ViewModel

Android 的 Activity 和 Composable 函数有个致命缺陷:它们像临时出场的证人,随时可能被系统"请走"。常见的触发场景包括:

如果把数据直接放在这些组件中,每次重建都意味着案件档案丢失。我们需要一个比证人更可靠的地方来保管案件资料 -- 这就是 ViewModel,侦探的专属笔记本。

ViewModel 的生命周期

ViewModel 的生命周期与 Activity 截然不同。追踪它的存活轨迹你会发现:当 Activity 因为配置变更(如旋转屏幕)而重建时,ViewModel 不会被销毁 -- 它会一直存活,直到 Activity 真正结束(用户按返回键退出,或调用 finish())。

生命周期对比

把 Activity 想象成办案的"外勤侦探",配置变更时外勤人员轮换了,但 ViewModel 是"案件档案室",无论外勤怎么换,案件档案始终完好保存。只有当案件真正结案(Activity 被 finish)时,档案才会销毁。

创建 ViewModel

首先,在 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:案件档案的追踪通道

在上面的代码中,我们使用了 StateFlow 来管理状态。你可以把它理解为一条从档案室到法庭(UI)的实时汇报通道。这是 Kotlin 协程库提供的一个特殊 Flow,非常适合追踪数据流向:

为什么用下划线前缀?

这是 Android 侦探界的一个行规:_count 是机密的可变档案,count 是对外的只读汇报。这种 "backing property" 模式确保了只有 ViewModel 这个"档案管理员"能修改数据,UI 层只能读取和观察,从而保证了单向数据流 -- 情报只从源头流向终端,绝不允许逆流篡改。

在 Compose 中使用 ViewModel

档案室建好了,现在要在法庭(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("+")
            }
        }
    }
}

追踪要点:

9.2 Room 数据库入门

ViewModel 解决了"外勤轮换"时的档案保全问题,但如果整个警局关门(App 被关闭)再开门呢?内存中的案件记录就真的消失了。要让证据永久留存,我们需要一个证物柜 -- 数据库。

Room 是什么

Room 是 Google 官方打造的 Android 证物柜方案。它本质上是 SQLite 之上的一个抽象层,但比直接操作 SQLite 可靠得多,就像用专业的证物管理系统取代了手写标签:

添加 Room 依赖

// 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")            // 注解处理器
}
注意:KSP 插件

Room 使用注解处理器来生成代码,你需要在项目中启用 KSP(Kotlin Symbol Processing)插件。在项目级 build.gradle.kts 中添加 id("com.google.devtools.ksp") version "...",并在模块级文件中应用 id("com.google.devtools.ksp")。装备不齐全,侦探可出不了任务。

Room 的三大组件

Room 的证物管理体系围绕三个核心角色展开,追踪每个角色的职责非常关键:

组件 注解 在案件中的角色
实体 (Entity) @Entity 证物本身 -- 定义每件证据的结构,每条记录是一件证物
数据访问对象 (DAO) @Dao 证物管理员 -- 负责证物的存入、取出、更新和销毁
数据库 (Database) @Database 证物柜本体 -- 数据库配置,是进入证物柜的唯一入口

让我们用一个待办事项(Todo)案件来理解这三大组件。

第一步:定义 Entity(登记证物)

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()
)

追踪解读:

第二步:定义 DAO(证物管理员)

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>
}

关键线索:

第三步:定义 Database(建造证物柜)

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),确保整栋大楼只有一个证物柜 -- 不能让证据散落在多个地方。

Flow 集成:实时追踪证物变动

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。整个过程完全是响应式的 -- 证物一入库,所有追踪者同步收到通知。

9.3 Retrofit 网络请求

本地证物柜搞定了,但要破解真正的大案,光靠内部证据远远不够。社交动态、新闻资讯、天气预报 -- 这些情报都来自外部世界。Retrofit 就是我们的情报网络,它是 Android 生态中最强大的线人系统,让从远程服务器获取情报变得像查阅本地档案一样简单。

Retrofit 是什么

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" />
没有这一行,你的情报网络一条线路都通不了!就像侦探出门办案忘了带警徽。

定义 API 接口

假设我们要联络一个天气情报来源,先定义情报的格式和联络方式:

// 情报格式(数据模型)
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

创建 Retrofit 实例

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}"
            }
        }
    }
}
协程 + Retrofit = 优雅的情报收集

对比传统的回调方式,协程让异步的情报收集看起来像同步的档案查阅一样直观。一个 try/catch 就能处理线人失联(网络错误)的情况,不需要写 onSuccess/onFailure 这种复杂的回调链。这正是我们在第6章学习协程时说的"用同步的方式写异步代码"。

9.4 实战:构建新闻列表 App

所有线索已经收集齐了,现在是破案的时刻。让我们把前面掌握的所有侦查手段串联起来,构建一个完整的新闻列表 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>
)

第二步:建立情报来源(API 服务接口)

// 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)

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 标记案件进展

侦查行动有三种状态:侦查中、情报到手、行动失败。用 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)

// 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 ?: "搜索失败"
                )
            }
        }
    }
}

第六步:法庭呈堂(Compose UI 层)

// 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
                )
            }
        }
    }
}

完整数据流向追踪

现在让我们像真正的侦探一样,追踪数据在整个应用中的完整流向。每一步都是一条不可遗漏的线索:

  1. 案件启动 -> ViewModel 的 init 块发出侦查指令 loadNews()
  2. 档案室将案件状态标记为 Loading(侦查中)-> 法庭显示等待指示器
  3. 档案室通过情报汇总中心(Repository)向线人网络(Retrofit)发起情报请求
  4. 线人网络在后台执行 HTTP 请求,将 JSON 密文解码为 Kotlin 对象
  5. 情报汇总中心将解码后的情报送回档案室
  6. 档案室将案件状态更新为 Success(articles)Error(message)
  7. 法庭(Compose)通过追踪通道感知到状态变化,自动重组 UI 呈现结果
// 完整数据流向追踪图
//
//  [法庭/UI]        [档案室/VM]        [情报中心/Repo]   [线人/API]
//     |                  |                  |               |
//     |  collectAsState  |                  |               |
//     |<-- StateFlow ---|                  |               |
//     |                  |--- loadNews() -->|               |
//     |                  |                  |-- HTTP GET -->|
//     |                  |                  |<-- JSON ------|
//     |                  |<-- articles ----|               |
//     |<-- 重组 UI ------|                  |               |
//
API Key 安全提醒

示例代码中的 "YOUR_API_KEY" 只是占位符。在实际项目中,绝对不要把 API Key 硬编码在源代码中 -- 这就像把警局的门禁密码写在大门上。推荐的做法是将其放在 local.properties 文件(已被 .gitignore 忽略)中,然后通过 BuildConfig 在编译时注入。

后续侦查方向

这个新闻 App 已经是一个完整的可运行案件了,但真正的侦探永远不会满足于此。你可以继续追踪以下方向:

本章案件结案陈词

本章我们追踪了 Android 应用中最核心的数据流向链路:用 ViewModel(案件档案室)管理 UI 状态并在人员轮换时保全档案;用 Room(证物柜)实现证据的永久保存;用 Retrofit(情报网络)从远程服务器收集情报。最后通过一个新闻列表 App,将整条侦查链路从头到尾串联起来。这个"ViewModel + Repository + Retrofit/Room"的架构模式,就是 Android 官方推荐的标准办案流程,也是几乎所有商业 App 都在使用的模式。掌握了这条数据流向,你就拥有了追踪任何案件的基本能力。真相只有一个 -- 数据的流向也一样。

本章练习

练习 1:侦探笔记本升级 入门

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()
    }
}

练习 2:证物柜搜索功能 进阶

TodoDao 中添加一个新方法 searchTodos,接收一个关键词参数 query: String,返回 Flow<List<Todo>>,查找 titledescription 中包含该关键词的所有待办事项,结果按创建时间降序排列。

提示

在 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>>
}

练习 3:情报网络扩展 进阶

WeatherApiService 添加一个新的 POST 请求方法 reportWeather,用于向服务器提交一份天气观测报告。请求路径为 "weather/report",需要在请求头中携带一个名为 Authorization 的认证令牌,请求体是一个 WeatherReport 对象(包含 city: Stringtemperature: Doublenotes: String 三个字段)。返回一个 ApiResult 对象(包含 success: Booleanmessage: String)。

提示

需要用到三个 Retrofit 注解:@POST 定义请求类型和路径,@Header 添加请求头参数,@Body 标记请求体。别忘了方法需要用 suspend 关键字修饰,以支持协程调用。先定义好 WeatherReportApiResult 两个数据类。

参考答案
// 天气观测报告(提交给服务器的情报)
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
}

练习 4:完整侦查链路 挑战

为新闻 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
    }
}
« 上一章 目录 下一章 »