30、综合实战:构建一个完整的MVVM应用,全面运用KTX与Jetpack KTX

终于到了最后一章。

说实话,写到这里我有点感慨。前面29章我们聊了那么多KTX的细节——从lifecycleScopeFlow,从SharedPreferencesRoom,从NavigationDataStore。但说真的,知识点学得再多,不落地到项目里,终究是纸上谈兵。

这一章,我们就来干一件实在的事:用Kotlin + KTX + Jetpack KTX,从零搭一个完整的MVVM应用。我会带着你走一遍我平时做项目的真实流程,把前面讲过的那些API全部串起来。

本章目标:构建一个「用户笔记」App。支持用户登录、查看笔记列表、新增笔记、离线缓存。全面使用KTX和Jetpack KTX优化代码。

30.1 项目架构总览

先画一张图,让你对整个项目有个直观印象。我个人习惯在动手写代码之前,先把架构图画清楚。这样后面写代码的时候,心里有谱,不会跑偏。

MVVM + KTX 综合架构图 UI Layer Activity / Fragment DataBinding + ViewBinding LiveData / Flow 观察 ViewModel Layer viewModelScope StateFlow / SharedFlow 协程 + 生命周期感知 Repository Layer 网络 + 本地数据源 Room KTX + Retrofit DataStore KTX 缓存 Remote Data Retrofit + OkHttp Local Data Room + DataStore DI Hilt KTX 注入 🧩 Android KTX 🚀 Jetpack KTX ⚡ Coroutines + Flow 🛡️ Lifecycle KTX

你看,整个架构分了三层:UI层、ViewModel层、Repository层。每一层都有对应的KTX组件在背后撑腰。说白了,KTX就是帮我们把那些啰嗦的模板代码砍掉,让每一层都写得干净利落。

30.2 项目依赖配置

先上build.gradle。我建议你用version catalog统一管理版本,这样后续升级依赖会方便很多。

// libs.versions.toml (部分)
[versions]
kotlin = "1.9.22"
coroutines = "1.7.3"
lifecycle = "2.7.0"
room = "2.6.1"
hilt = "2.50"
retrofit = "2.9.0"
datastore = "1.0.0"

[libraries]
# KTX 核心
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version = "1.12.0" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" }
androidx-lifecycle-viewmodel-ktx = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-ktx", version.ref = "lifecycle" }
androidx-lifecycle-livedata-ktx = { group = "androidx.lifecycle", name = "lifecycle-livedata-ktx", version.ref = "lifecycle" }

# Room KTX
androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }

# DataStore KTX
androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }

# Hilt
androidx-hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version = "1.1.0" }

💡 我的习惯:所有KTX依赖统一放在一个组里,用注释标明用途。这样以后找依赖、升级版本,一目了然。我曾经在一个项目里因为版本号散落各处,升级时漏了一个,结果编译报错查了半天……

30.3 数据层:Room + DataStore + Retrofit

数据层是App的基石。我们先用Room KTX定义DAO,再用DataStore KTX做偏好缓存,最后用Retrofit拉网络数据。

30.3.1 Room DAO 与 KTX

Room KTX最爽的地方是什么?就是DAO方法可以直接挂起,不用写回调。你想想看,以前写Room查询,要么用LiveData,要么写AsyncTask,多麻烦。现在直接suspend搞定。

@Dao
interface NoteDao {
    @Query("SELECT * FROM notes ORDER BY createdAt DESC")
    fun getAllNotes(): Flow<List<NoteEntity>>

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertNote(note: NoteEntity)

    @Delete
    suspend fun deleteNote(note: NoteEntity)

    @Query("SELECT * FROM notes WHERE id = :noteId")
    suspend fun getNoteById(noteId: Long): NoteEntity?
}

注意看,getAllNotes()返回的是Flow。这意味着只要数据库里的数据变了,UI层会自动收到通知。这就是Room KTX + Flow的魔力——数据驱动UI,完全不用手动刷新。

30.3.2 DataStore KTX 做偏好缓存

用户登录后,我们需要存一个token。以前用SharedPreferences,代码又臭又长。现在用DataStore KTX,清爽多了。

class UserPreferences(private val dataStore: DataStore<Preferences>) {

    companion object {
        val TOKEN_KEY = stringPreferencesKey("user_token")
        val USER_NAME_KEY = stringPreferencesKey("user_name")
    }

    val tokenFlow: Flow<String?> = dataStore.data
        .map { preferences -> preferences[TOKEN_KEY] }

    suspend fun saveToken(token: String) {
        dataStore.edit { preferences ->
            preferences[TOKEN_KEY] = token
        }
    }

    suspend fun clearAll() {
        dataStore.edit { it.clear() }
    }
}

⚠️ 注意:dataStore.edit是挂起函数,必须在协程里调用。我见过有人直接在UI线程调,结果崩了。记住,DataStore的所有写操作都是异步的,这是它比SharedPreferences安全的地方。

30.3.3 Repository 层:网络+本地缓存

Repository是数据层的门面。我习惯在这里做「网络优先,本地兜底」的策略。

class NoteRepository @Inject constructor(
    private val noteDao: NoteDao,
    private val noteApi: NoteApi,
    private val userPreferences: UserPreferences
) {
    // 从本地数据库获取笔记流
    fun getNotesFlow(): Flow<List<Note>> {
        return noteDao.getAllNotes().map { entities ->
            entities.map { it.toDomainModel() }
        }
    }

    // 刷新笔记:先拉网络,再存本地
    suspend fun refreshNotes(): Result<Unit> {
        return try {
            val token = userPreferences.tokenFlow.first() ?: return Result.failure(Exception("未登录"))
            val remoteNotes = noteApi.fetchNotes("Bearer $token")
            noteDao.insertNotes(remoteNotes.map { it.toEntity() })
            Result.success(Unit)
        } catch (e: Exception) {
            Result.failure(e)
        }
    }
}

这里用到了Flow.first()——KTX提供的扩展函数,可以拿到Flow的第一个值然后取消订阅。很适合这种「取一次值」的场景。

30.4 ViewModel 层:全面拥抱 KTX

ViewModel是MVVM的核心。我们用viewModelScope管理协程,用StateFlow暴露状态,用SharedFlow处理一次性事件。

@HiltViewModel
class NoteViewModel @Inject constructor(
    private val noteRepository: NoteRepository,
    private val userPreferences: UserPreferences
) : ViewModel() {

    // UI状态:用StateFlow
    private val _uiState = MutableStateFlow(NoteUiState())
    val uiState: StateFlow<NoteUiState> = _uiState.asStateFlow()

    // 一次性事件:用SharedFlow
    private val _event = MutableSharedFlow<NoteEvent>()
    val event: SharedFlow<NoteEvent> = _event.asSharedFlow()

    init {
        // 进入页面就加载笔记
        loadNotes()
    }

    private fun loadNotes() {
        viewModelScope.launch {
            _uiState.update { it.copy(isLoading = true) }

            // 先展示本地缓存
            noteRepository.getNotesFlow().collect { notes ->
                _uiState.update { it.copy(notes = notes, isLoading = false) }
            }
        }
    }

    fun refresh() {
        viewModelScope.launch {
            _uiState.update { it.copy(isRefreshing = true) }
            val result = noteRepository.refreshNotes()
            if (result.isFailure) {
                _event.emit(NoteEvent.ShowSnackbar("刷新失败: ${result.exceptionOrNull()?.message}"))
            }
            _uiState.update { it.copy(isRefreshing = false) }
        }
    }
}

你看,viewModelScope.launchStateFlow.updateSharedFlow.emit——全是KTX提供的扩展。没有这些,你得手动写线程切换、状态合并、事件队列……代码量至少翻一倍。

核心要点:ViewModel里不要持有Context,不要直接操作View。所有数据通过Flow暴露,UI层去订阅。这样ViewModel才能做到「生命周期感知」和「配置变更不丢失数据」。

30.5 UI 层:DataBinding + Lifecycle KTX

UI层我们用DataBinding + Fragment。Lifecycle KTX提供了lifecycleScoperepeatOnLifecycle,让我们安全地收集Flow。

class NoteListFragment : Fragment(R.layout.fragment_note_list) {

    private val viewModel: NoteViewModel by viewModels()
    private var _binding: FragmentNoteListBinding? = null
    private val binding get() = _binding!!

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        _binding = FragmentNoteListBinding.bind(view)

        // 使用 lifecycleScope 安全收集 Flow
        viewLifecycleOwner.lifecycleScope.launch {
            viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
                viewModel.uiState.collect { state ->
                    // 更新UI
                    binding.progressBar.isVisible = state.isLoading
                    binding.recyclerView.adapter?.submitList(state.notes)
                }
            }
        }

        // 收集一次性事件
        viewLifecycleOwner.lifecycleScope.launch {
            viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
                viewModel.event.collect { event ->
                    when (event) {
                        is NoteEvent.ShowSnackbar -> {
                            Snackbar.make(binding.root, event.message, Snackbar.LENGTH_SHORT).show()
                        }
                    }
                }
            }
        }
    }

    override fun onDestroyView() {
        super.onDestroyView()
        _binding = null
    }
}

这里有个细节:repeatOnLifecycle会在Fragment进入STARTED状态时开始收集,进入STOPPED状态时自动取消。这样就不会在后台浪费资源,也不会因为Fragment销毁后还更新UI而崩溃。

💡 避坑指南:我曾经在项目里直接用lifecycleScope.launch收集Flow,没有用repeatOnLifecycle。结果Fragment退到后台后,Flow还在跑,更新UI时抛异常。后来全部改成repeatOnLifecycle,世界清净了。

30.6 依赖注入:Hilt KTX

Hilt KTX让我们可以轻松注入ViewModel、Repository、DataStore等。配合@HiltViewModel注解,连ViewModel的工厂类都不用写了。

@Module
@InstallIn(SingletonComponent::class)
object AppModule {

    @Provides
    @Singleton
    fun provideDataStore(@ApplicationContext context: Context): DataStore<Preferences> {
        return context.dataStore
    }

    @Provides
    @Singleton
    fun provideNoteApi(): NoteApi {
        return Retrofit.Builder()
            .baseUrl("https://api.example.com/")
            .addConverterFactory(GsonConverterFactory.create())
            .build()
            .create(NoteApi::class.java)
    }
}

然后在Activity或Fragment里,直接用by viewModels()或者hiltNavGraphViewModels()获取ViewModel,Hilt会自动注入依赖。

30.7 完整流程串联

好了,我们把所有环节串起来,看看一次「用户打开App → 查看笔记 → 下拉刷新」的完整流程:

  1. App启动:Hilt初始化,注入所有依赖。
  2. 进入NoteListFragment:ViewModel的init块调用loadNotes()
  3. 加载本地数据noteRepository.getNotesFlow()从Room读取笔记,返回Flow
  4. UI更新:Fragment通过repeatOnLifecycle收集uiState,展示笔记列表。
  5. 下拉刷新:调用viewModel.refresh(),从网络拉取最新数据。
  6. 网络请求:Retrofit发起请求,携带DataStore中存储的token。
  7. 数据回写:网络数据写入Room,Room的Flow自动通知UI更新。
  8. 错误处理:如果网络失败,通过SharedFlow发送Snackbar事件。

整个过程,没有回调,没有LiveData的粘性问题,没有手动线程切换。全部由KTX和Jetpack KTX在背后默默搞定。

30.8 总结与心得

写到这里,我想说几句心里话。

KTX和Jetpack KTX不是银弹,但它们确实让Android开发变得「更像Kotlin」了。我刚开始用Java写Android的时候,一个简单的网络请求要写AsyncTask、Handler、接口回调……代码又臭又长。现在呢?一个viewModelScope.launch就搞定了。

但工具再好,架构才是根本。MVVM + Repository + DI,这套组合拳打好了,你的App才能经得起迭代。KTX只是帮你把代码写得更优雅,但设计模式、分层思想、数据流向——这些才是真正决定代码质量的东西。

嗯,30章的内容,说多不多,说少不少。如果你能跟着一路写下来,我相信你对KTX的理解已经超过大多数开发者了。以后在项目里看到.asLiveData().flowWithLifecycle()dataStore.edit这些API,你应该能会心一笑——「老朋友了」。

最后送你一句话:写代码就像盖房子,KTX是趁手的工具,MVVM是稳固的框架。工具趁手,框架稳固,你才能盖出经得起风雨的大厦。

公众号:蓝海资料掘金营,微信deep3321