Android Jetpack 核心组件实战:ViewModel + LiveData + DataBinding 详解

作者:马 孔 多 在下雨日期:2025/10/5

Android Jetpack 核心组件实战:ViewModel + LiveData + DataBinding 详解

在 Android 开发中,我们经常会遇到屏幕旋转数据丢失UI 与逻辑耦合紧密数据更新无法自动同步 UI 等问题。Google 推出的 Jetpack 架构组件可以很好地解决这些问题,本文将对 ViewModelLiveDataDataBinding 三个核心组件进行讲解,从基础概念到实战案例,完整讲解这三个组件的使用方法与联动逻辑。

一、ViewModel:解决配置变更数据丢失问题

1. 为什么需要 ViewModel?

当 Activity 因屏幕旋转内存回收等配置变更被销毁重建时,Activity 中的数据(如计数器、网络请求结果)会随之丢失。如果在 Activity 中直接处理数据,不仅会导致重复加载(如重复发起网络请求),还会造成用户体验差、性能浪费等问题。

ViewModel 的核心作用就是:存储和管理与 UI 相关的数据,且生命周期独立于 Activity/Fragment 的重建。即使页面重建,ViewModel 中的数据依然保留,新页面可直接复用。

2. 添加依赖

app/build.gradledependencies 块中添加 ViewModel 依赖(最新版本可在 Jetpack 官网 查询):

1dependencies {
2    // ViewModel 核心依赖
3    implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.0"
4}
5

3. 定义 ViewModel 类

ViewModel 需继承 ViewModel 基类,内部存储需要保留的数据,并提供操作数据的方法。以“计数器”为例:

1import androidx.lifecycle.ViewModel
2
3class CounterViewModel : ViewModel() {
4    // 存储计数数据(初始值为 0)
5    private var counter = 0
6
7    // 获取当前计数
8    fun getCounter(): Int = counter
9
10    // 计数 +1
11    fun plusOne() {
12        counter++
13    }
14
15    // 重置计数
16    fun clear() {
17        counter = 0
18    }
19}
20
  • 注意:ViewModel 中不要持有 Activity/Fragment 的引用(如 Context),否则会因生命周期不匹配导致内存泄漏。若需 Context,可使用 AndroidViewModel(需传入 Application)。

4. 在 Activity 中使用 ViewModel

通过 ViewModelProvider 获取 ViewModel 实例(而非直接 new),确保配置变更后获取的是同一个实例:

1import androidx.appcompat.app.AppCompatActivity
2import androidx.lifecycle.ViewModelProvider
3import android.os.Bundle
4import android.widget.Button
5import android.widget.TextView
6
7class MainActivity : AppCompatActivity() {
8    private lateinit var viewModel: CounterViewModel
9    private lateinit var tvCounter: TextView
10    private lateinit var btnAdd: Button
11    private lateinit var btnClear: Button
12
13    override fun onCreate(savedInstanceState: Bundle?) {
14        super.onCreate(savedInstanceState)
15        setContentView(R.layout.activity_main)
16
17        // 初始化 UI 组件
18        tvCounter = findViewById(R.id.tv_counter)
19        btnAdd = findViewById(R.id.btn_add)
20        btnClear = findViewById(R.id.btn_clear)
21
22        // 关键:通过 ViewModelProvider 获取 ViewModel 实例
23        // this 表示当前 Activity(ViewModel 的作用域)
24        viewModel = ViewModelProvider(this)[CounterViewModel::class.java]
25
26        // 初始显示计数
27        updateCounterUI()
28
29        // 点击“+1”按钮
30        btnAdd.setOnClickListener {
31            viewModel.plusOne()
32            updateCounterUI() // 手动更新 UI
33        }
34
35        // 点击“重置”按钮
36        btnClear.setOnClickListener {
37            viewModel.clear()
38            updateCounterUI() // 手动更新 UI
39        }
40    }
41
42    // 手动更新 TextView 显示
43    private fun updateCounterUI() {
44        tvCounter.text = "当前计数:${viewModel.getCounter()}"
45    }
46}
47

效果:点击按钮后计数增加,旋转屏幕后计数不会归零——ViewModel 成功保留了数据。

5. ViewModel.Factory:处理带参构造的 ViewModel

上述 ViewModel 是无参构造的,但实际开发中常需要传入初始值(如从 SharedPreferences 读取的历史计数)。此时需自定义 ViewModel.Factory 来创建带参 ViewModel。

  • 步骤 1:定义 Factory 类
1import androidx.lifecycle.ViewModel
2import androidx.lifecycle.ViewModelProvider
3
4// 接收初始计数作为参数
5class CounterViewModelFactory(private val initialCount: Int) : ViewModelProvider.Factory {
6    // 重写 create 方法,创建带参 ViewModel
7    override fun <T : ViewModel> create(modelClass: Class<T>): T {
8        // 检查 modelClass 是否为 CounterViewModel 类型
9        if (modelClass.isAssignableFrom(CounterViewModel::class.java)) {
10            // 传入初始值创建实例
11            return CounterViewModel(initialCount) as T
12        }
13        throw IllegalArgumentException("未知的 ViewModel 类")
14    }
15}
16
  • 步骤 2:修改 ViewModel 支持带参构造
1class CounterViewModel(private val initialCount: Int) : ViewModel() {
2    private var counter = initialCount // 用初始值初始化
3
4    // 其余方法不变...
5}
6
  • 步骤 3:通过 Factory 获取 ViewModel
1// 从 SharedPreferences 读取历史计数(示例)
2val sp = getPreferences(MODE_PRIVATE)
3val savedCount = sp.getInt("saved_count", 0)
4
5// 通过 Factory 传入初始值
6viewModel = ViewModelProvider(
7    this,
8    CounterViewModelFactory(savedCount) // 传入初始计数
9)[CounterViewModel::class.java]
10

6. ViewModel 生命周期关键点

  • 创建时机:首次调用 ViewModelProvider.get() 时创建。
  • 销毁时机:仅当 Activity/Fragment 被永久销毁(如用户按返回键)时,系统才会调用 ViewModel.onCleared() 释放资源(可在此处取消网络请求、解绑观察者等)。
  • 作用域:同一个 ViewModelStoreOwner(如 Activity、Fragment)内,同一类型的 ViewModel 仅存在一个实例。

二、LiveData:实现数据变化自动更新 UI

1. 为什么需要 LiveData?

ViewModel 解决了数据保留问题,但无法主动将数据变化通知给 UI——上述案例中,我们需要手动调用 updateCounterUI() 刷新界面。若数据更新频繁(如实时定位、网络流),手动更新会导致代码冗余且易出错。

LiveData 是一种可观察的数据容器,核心能力:

  • 生命周期感知:自动跟随 Activity/Fragment 的生命周期,仅在页面活跃时(onStart 后)通知数据变化,避免内存泄漏。
  • 自动通知 UI:数据更新时,自动回调观察者,无需手动刷新 UI。

2. LiveData 基础用法(结合 ViewModel)

步骤 1:用 LiveData 包装数据

LiveData 是抽象类,通常使用其子类 MutableLiveData(支持修改数据)。为保证封装性,对外暴露不可变的 LiveData,内部用 MutableLiveData 修改数据

1import androidx.lifecycle.LiveData
2import androidx.lifecycle.MutableLiveData
3import androidx.lifecycle.ViewModel
4
5class CounterViewModel(initialCount: Int) : ViewModel() {
6    // 私有可变 LiveData(内部修改)
7    private val _counter = MutableLiveData<Int>()
8    // 公开不可变 LiveData(外部仅能观察)
9    val counter: LiveData<Int> = _counter
10
11    init {
12        // 初始化数据(LiveData 通过 value 存取值)
13        _counter.value = initialCount
14    }
15
16    fun plusOne() {
17        // 获取当前值(为空时默认 0)
18        val currentCount = _counter.value ?: 0
19        _counter.value = currentCount + 1 // 主线程更新数据
20    }
21
22    fun clear() {
23        _counter.value = 0
24    }
25
26    // 非主线程更新数据(如子线程网络请求后)
27    fun postCount(newCount: Int) {
28        _counter.postValue(newCount) // 内部会切换到主线程
29    }
30}
31
  • setValue() vs postValue()
    • setValue():仅能在主线程调用,立即更新数据。
    • postValue():可在子线程调用,通过 Handler 切换到主线程更新数据(适合网络请求、数据库操作等异步场景)。
步骤 2:在 Activity 中观察 LiveData

通过 LiveData.observe() 注册观察者,数据变化时自动回调 onChanged 方法:

1class MainActivity : AppCompatActivity() {
2    private lateinit var viewModel: CounterViewModel
3    private lateinit var tvCounter: TextView
4    private lateinit var btnAdd: Button
5    private lateinit var btnClear: Button
6    private lateinit var sp: SharedPreferences
7
8    override fun onCreate(savedInstanceState: Bundle?) {
9        super.onCreate(savedInstanceState)
10        setContentView(R.layout.activity_main)
11
12        // 初始化 SharedPreferences(用于保存历史计数)
13        sp = getPreferences(MODE_PRIVATE)
14        val savedCount = sp.getInt("saved_count", 0)
15
16        // 通过 Factory 获取带参 ViewModel
17        viewModel = ViewModelProvider(
18            this,
19            CounterViewModelFactory(savedCount)
20        )[CounterViewModel::class.java]
21
22        // 初始化 UI 组件
23        tvCounter = findViewById(R.id.tv_counter)
24        btnAdd = findViewById(R.id.btn_add)
25        btnClear = findViewById(R.id.btn_clear)
26
27        // 关键:观察 LiveData 变化(自动更新 UI)
28        viewModel.counter.observe(this) { newCount ->
29            // 数据变化时回调,更新 TextView
30            tvCounter.text = "当前计数:$newCount"
31        }
32
33        // 点击“+1”按钮(仅操作数据,无需手动更新 UI)
34        btnAdd.setOnClickListener {
35            viewModel.plusOne()
36        }
37
38        // 点击“重置”按钮
39        btnClear.setOnClickListener {
40            viewModel.clear()
41        }
42    }
43
44    // 页面暂停时保存计数到 SharedPreferences
45    override fun onPause() {
46        super.onPause()
47        sp.edit().putInt("saved_count", viewModel.counter.value ?: 0).apply()
48    }
49}
50

核心变化:移除了 updateCounterUI() 手动刷新逻辑,LiveData 自动将数据变化同步到 UI。

3. LiveData 高级用法:map 与 switchMap

(1)map:数据转换

当需要将 LiveData 中的原始数据转换为 UI 所需的格式时,使用 map()。例如:将 User 对象转换为“姓名+年龄”的字符串。

1import androidx.lifecycle.LiveData
2import androidx.lifecycle.MutableLiveData
3import androidx.lifecycle.map
4import androidx.lifecycle.ViewModel
5
6// 原始数据类
7data class User(val name: String, val age: Int)
8
9class UserViewModel : ViewModel() {
10    // 原始 LiveData(存储 User 对象)
11    private val _user = MutableLiveData<User>()
12    // 转换后的 LiveData(仅暴露姓名+年龄字符串)
13    val userInfo: LiveData<String> = _user.map { user ->
14        "${user.name}${user.age}岁)"
15    }
16
17    // 模拟更新用户数据
18    fun updateUser(newUser: User) {
19        _user.value = newUser
20    }
21}
22

在 Activity 中观察 userInfo,即可直接获取转换后的字符串:

1viewModel.userInfo.observe(this) { info ->
2    tvUserInfo.text = info // 直接显示“张三(20岁)”
3}
4
(2)switchMap:动态切换 LiveData

当 LiveData 的数据源需要动态切换时(如根据用户 ID 加载不同用户数据),使用 switchMap()。例如:根据随机生成的 userId 加载对应的 User

1import androidx.lifecycle.LiveData
2import androidx.lifecycle.MutableLiveData
3import androidx.lifecycle.SwitchMap
4import androidx.lifecycle.ViewModel
5
6// 模拟数据仓库(如网络请求、数据库查询)
7object UserRepository {
8    // 根据 userId 获取 User(模拟耗时操作)
9    fun getUserById(userId: String): LiveData<User> {
10        val liveData = MutableLiveData<User>()
11        liveData.value = User("用户$userId", userId.toInt() % 30 + 18) // 模拟年龄
12        return liveData
13    }
14}
15
16class UserViewModel : ViewModel() {
17    // 触发数据源切换的“开关”LiveData(存储 userId)
18    private val _userId = MutableLiveData<String>()
19
20    // 动态切换的 LiveData(根据 userId 加载不同 User)
21    val user: LiveData<User> = _userId.switchMap { userId ->
22        UserRepository.getUserById(userId)
23    }
24
25    // 对外提供方法,更新 userId(触发数据源切换)
26    fun loadUser(userId: String) {
27        _userId.value = userId
28    }
29}
30

在 Activity 中点击按钮切换 userIduser 会自动加载新数据并更新 UI:

1// 初始加载随机用户
2viewModel.loadUser((0..1000).random().toString())
3
4// 点击“切换用户”按钮
5btnSwitchUser.setOnClickListener {
6    val newUserId = (0..1000).random().toString()
7    viewModel.loadUser(newUserId)
8}
9
10// 观察用户数据变化
11viewModel.user.observe(this) { user ->
12    tvUserInfo.text = "${user.name}${user.age}岁)"
13}
14

三、DataBinding:消除模板代码,实现 UI 与数据绑定

1. 为什么需要 DataBinding?

传统开发中,我们需要通过 findViewById 获取 UI 组件引用,再手动设置数据(如 textonClick),代码冗余且耦合度高。DataBinding 是一种数据绑定库,可直接在 XML 中关联数据与 UI,消除模板代码,实现“XML 绑定数据,数据驱动 UI”。

2. 启用 DataBinding

app/build.gradleandroid 块中开启 DataBinding:

1android {
2    ...
3    buildFeatures {
4        dataBinding = true // 启用 DataBinding
5    }
6}
7

3. 实战:ViewModel + LiveData + DataBinding 联动

下面以一个简单的计数器的例子结合着ViewModel和LiveData使用。先看一下这个VIewModel类,就是个简单的计数器

1class CounterViewModel: ViewModel() {
2    val counter : LiveData<Int>
3        get() = _counter
4
5    private val _counter = MutableLiveData<Int>()
6
7    init {
8        _counter.value = count
9    }
10    fun plusOne() {
11        val count = counter.value ?: 0
12        _counter.value = count + 1
13    }
14
15    fun clear() {
16        _counter.value = 0
17    }
18
19
20}
21

接着修改xml文件,dataBinding绑定的布局文件的根布局必须使用layout,只能存在一个<data>作用域和一个直接view子节点(比如)

在data作用域中声明数据变量,然后在对应的ui组件中添加组件的指向属性(例如下面TextView的[android:text="@{计数: + String.valueOf(vm.counter)}"](function valueOf() { [native code] }))

然后还在Button的点击事件上绑定了ViewModel中的pulsOne()方法,这样每次点击视图就会自动给ViewModel中的value+1,然后TextView中的counter也会+1,实现了双向绑定。不过注意,TextView中绑定的是vm的counter,这是一个LiveData,也就是容器,真正存值的是LiveData的value,但是DataBinding 对 LiveData 有特殊支持:当你在 XML 中写 @{vm.counter} 时,DataBinding 会自动观察这个 LiveData 的变化,并且直接使用它的 value 值(相当于在代码中获取 counter.value)。

1<?xml version="1.0" encoding="utf-8"?>
2<layout xmlns:android="http://schemas.android.com/apk/res/android"
3    xmlns:app="http://schemas.android.com/apk/res-auto"
4    xmlns:tools="http://schemas.android.com/tools">
5
6    <data>
7        <variable
8            name="myCount"
9            type="int" />
10        <variable
11            name="vm"
12            type="com.example.databindingtest.CounterViewModel" />
13    </data>
14
15    <androidx.constraintlayout.widget.ConstraintLayout
16        android:id="@+id/main"
17        android:layout_width="match_parent"
18        android:layout_height="match_parent"
19        tools:context=".MainActivity">
20
21        <Button
22            android:id="@+id/button"
23            android:layout_width="wrap_content"
24            android:layout_height="wrap_content"
25            android:text="Button"
26            app:layout_constraintTop_toTopOf="parent"
27            android:layout_marginTop="15dp"
28            app:layout_constraintStart_toStartOf="parent"
29            app:layout_constraintEnd_toEndOf="parent"
30            android:onClick="@{() -> vm.plusOne()}"
31            />
32
33        <TextView
34            android:id="@+id/textView"
35            android:layout_width="wrap_content"
36            android:layout_height="wrap_content"
37            android:text="@{`计数:` + String.valueOf(vm.counter)}"
38            android:textSize="28sp"
39            app:layout_constraintTop_toBottomOf="@id/button"
40            app:layout_constraintStart_toStartOf="parent"
41            app:layout_constraintEnd_toEndOf="parent" />
42
43
44    </androidx.constraintlayout.widget.ConstraintLayout>
45</layout>
46
47

最后在对应的活动文件中通过 DataBinding 生成的绑定类,将数据与布局关联。首先通过 DataBindingUtil.setContentView() 方法,将 XML 布局文件 (R.layout.activity_main) 填充并解析,生成一个对应的绑定类(例如 ActivityMainBinding)的实例并得到返回的binding对象,将活动的viewModel给dataBinding的viewModel,然后显式绑定当前页面和databinding的生命周期

1class MainActivity : AppCompatActivity() {
2    private lateinit var binding : ActivityMainBinding
3    lateinit var viewModel : CounterViewModel
4    override fun onCreate(savedInstanceState: Bundle?) {
5        super.onCreate(savedInstanceState)
6        enableEdgeToEdge()
7        viewModel = ViewModelProvider(this).get(CounterViewModel::class.java)
8        binding = DataBindingUtil.setContentView(this, R.layout.activity_main)
9        binding.vm = viewModel
10        binding.lifecycleOwner = this
11    }
12}
13

这里解释一下binding.lifecycleOwner = this,这是在告诉 DataBinding:当前页面的生命周期 = 这个 Activity 的生命周期。我们可能不太理解,明明都在 Activity 里了,为什么还要多此一举告诉它这是 Activity?这是因为DataBinding 不是 Activity 的一部分,它只是一个独立生成的“绑定类对象”,默认不知道要跟谁的生命周期同步。我们必须手动告诉它:LiveData 的观察者应该跟随这个 Activity(或 Fragment)销毁时自动解除订阅。

这样就完成了一个简单的DataBinding、ViewModel和LiveData的联动了


Android Jetpack 核心组件实战:ViewModel + LiveData + DataBinding 详解》 是转载文章,点击查看原文


相关推荐


从 “Hello AI” 到企业级应用:Spring AI 如何重塑 Java 生态的 AI 开发
草莓熊Lotso2025/10/4

🔥个人主页:@草莓熊Lotso 🎬作者简介:C++研发方向学习者 📖个人专栏: 《C语言》 《数据结构与算法》《C语言刷题集》《Leetcode刷题指南》 ⭐️人生格言:生活是默默的坚持,毅力是永久的享受。 前言:当大模型浪潮席卷软件开发领域时,Java 开发者常常面临一个困境:一边是 PyTorch、LangChain 等 Python 生态的 AI 工具链蓬勃发展,一边是企业现有系统中大量的 Spring 技术栈难以快速接入 AI 能力。而 Spring AI 的出现


Vue3 中的双向链表依赖管理详解与示例
excel2025/10/3

在 Vue3 的响应式系统中,双向链表是一个非常重要的数据结构。相比 Vue2 使用数组来存放依赖,Vue3 选择链表的原因在于效率更高,尤其是在频繁收集和清理依赖时,链表可以显著优化性能。本文将通过讲解和示例代码,帮助你理解这一点。 为什么要用双向链表 在响应式依赖收集过程中,Vue 需要完成两件事: 收集依赖:当访问响应式数据时,要记录当前副作用函数(effect)。 清理依赖:当副作用函数重新运行或失效时,需要把它从依赖集合里移除。 如果依赖集合使用数组: 删除某个依赖需要遍历整个


CodeBuddy配套:如何配置AI编程总工程师spec-kit
小虎AI生活2025/10/2

我是小虎,浙江大学计算机本硕,专注AI编程。 如果AI能像总工程师一样,先帮你把图纸画好,再动手干活,那该多爽? AI编程学习群里,有学员在吐槽,AI编程时经常“失忆”,写着着就忘了前面的上下文,让人抓狂 🤯。 这不仅是学员们踩过的坑,也是我自己的惨痛教训。 昨天我也写了一篇文章,介绍我的土办法。 [CodeBuddy实战:防止AI编程跑偏的土办法,能抓老鼠就是好猫!] 今天,我要给你们安利一个刚出炉的神器,它能彻底改变你和AI协作写代码的方式。 而且,我敢说,全网我可能是第一篇教程写C


推荐 6 个本周 yyds 的 GitHub 项目。
喜爱编程2025/10/2

01 微软开源的文档转换工具 MarkItDown 用于将各种常见格式的文档转换为 Markdown 格式。 包括 PDF、PPT、Word、Excel、图片、音频、HTML、JSON等,甚至还能处理 ZIP 压缩包内的多个文件、YouTube 视频转录文本以及电子书 EPub 等。 它尤其适合需要将文档内容提取为结构化文本,并用于大模型处理或文本分析任务的情景。 其实仅限这个场景,因为项目官方说可以保留表格、排版啥的,实测并没有。。。 PDF 文件转换,左边屏是源文


【数据挖掘】基于随机森林回归模型的二手车价格预测分析(数据集+源码)
码银10/2/2025

本研究运用随机森林回归模型对汽车价格进行预测。通过对包含多种汽车属性的数据集进行预处理,包括对分类变量的独热编码,将其划分为训练集与测试集。利用训练集数据拟合随机森林模型,并使用测试集数据进行预测与评估。同时,借助多种可视化手段深入分析模型性能与数据特征。数据源:https://www.kaggle.com/datasets/vrajesh0sharma7/used-car-price-prediction选择随机森林回归模型作为预测工具,并设定决策树数量为100(),同时固定随机种子(


iOS 26 系统流畅度剖析:Liquid Glass 动画表现 + 用户反馈
代码背锅人日志9/30/2025

本文聚焦 iOS 26 系统流畅度,结合用户反馈、Liquid Glass 视觉变革与性能挑战,介绍如何用 KeyMob + Instruments 记录帧率 /卡顿 /动画延迟,并给出实战流程与优化建议,帮助开发者评估新版系统中界面的真实流畅性。


优先级队列(堆)-1046.最后一块砖的重量-力扣(LeetCode)
1白天的黑夜19/30/2025

个人主页:1白天的黑夜1-CSDN博客专栏:力扣刷题录_1白天的黑夜1的博客-CSDN博客、企鹅程序员:Linux 系统与网络编程_1白天的黑夜1的博客-CSDN博客目录一、题目解析1、选出两块最重的石头意为第一重和第二重或同样重2、如果只剩一块石头,返回石头的重量;如果没有石头返回0二、算法原理解法:优先级队列解法步骤:三、代码示例一、题目解析优先级队列就是堆,而堆又有大根堆和小根堆,本题需要用到的就是大根堆这里模板参数Compare的缺省值为less,也就是按照从根往下,根比孩子大;而小根堆则是grea


v你真的会记笔记吗?AI的答案可能让你意外
万少 VIP.5 如鱼得水2025/10/7

这段时间我在准备一个行业调查,调研资料几乎全来自视频会议、线上讲座和播客。 内容是很丰富,但问题也随之而来:一个小时的视频回放,想找个观点得快进倒退十几次,遇到灵感还得赶紧切出去做笔记,效率低到崩溃。 看不完,根本看不完…… 正好我朋友是一个AI发烧友,他就推荐我用了一个专注做AI笔记的工具。 坦白讲,最开始我没抱太大期待,心想不就是转写嘛。但真用了两周后,我发现它完全改变了我的学习和工作流。 这个工具叫Ai好记: 网址:aihaoji.com/zh?utm_sour… 输入口令【万少】可以


JAVA算法练习题day34
QiZhang6032025/10/8

43.验证二叉搜索树 要知道二叉搜索树的中序遍历结果是升序序列 # Definition for a binary tree node. # class TreeNode(object): # def __init__(self, val=0, left=None, right=None): # self.val = val # self.left = left # self.right = right class Solution(o


面试真实经历某节跳动大厂Java和算法问答以及答案总结(一)
360_go_php2025/10/10

Java面试问题与解答 常见的GC回收器 - Serial GC: 适合单线程环境,暂停时间较长。 - Parallel GC: 多线程垃圾回收,适合多核处理器,停顿时间较短。 - CMS (Concurrent Mark-Sweep): 适合响应时间要求高的应用,通过多线程并发清除垃圾。 - G1 GC: 适用于大内存系统,目标是尽量减少GC停顿时间,分区回收。​编辑 SpringMVC的请求过程 - 流程: 用户发起请求 → 前端控制器(DispatcherServlet)接收请求

首页编辑器站点地图

Copyright © 2025 聚合阅读

License: CC BY-SA 4.0