原创内容,请勿转发!!! 针对很多转行到Kotlin的软件工程师,网上或者有很多讲解Kotlin+MVVM的视频教程,但大多数都是很早以前的技术,部分方案甚至没有结合viewBinding来做,甚至连最基础的lifecycle等依赖库配置都没细说就开始讲MVVM+Kotlin了,在此特意编写该文章以确保有疑虑的新手可以加深对kotlin,MVVM软件模式的理解。
一、新建Kotlin工程
File-> New Project,在“Phone and Tablet”中选择 “Empty Views Activity”(不要选"Empty Activity")。如下图所示:
填充工程名,注意不要选择"Groovy DSL(build.gradle)"要选择"Kotlin DSL(build.gradle.kts)"
接着把工程关闭,否则可能要多等二十几分钟哦,接下来改gradle镜像地址
用VS Code(推荐)或者其他软件打开gradle-wrapper.properties文件,接着使用通义灵码(今天突然变成了QODER CN,是要跟TRAE CN靠齐了吗),然后打开"智能体``可以自动修复代码,选中第5行后,文本框中输入“改成腾讯的镜像地址”, 点击"接受"。如下图所示:
接着用 Android Studio重新打开项目
卡在这了,怎么办??
删除上图的
.gradle 和 .idea 两个目录,然后
接着神奇的事情发生了,最快一分钟不到就下载完了Gradle依赖,如果是Service可能要花十几分钟。
模拟器问题:
出现安装问题,及时问
通义灵码的智能体(不是智能问答)
又开始卡了,先忍1分钟,如果超过1分钟还没出来,那就 adb devices后直接kill进程,换个模拟器。经过40秒漫长的等待,效果如下:
二、添加MVM必须的依赖库
首先在模块级别的build.gradle.kts(Module:app不是项目级别Project:MVVMDemo)中的 dependencies中添加下面的依赖库:
implementation(libs.androidx.lifecycle.viewmodel)
implementation(libs.androidx.lifecycle.livedata)
implementation(libs.androidx.recyclerview)
implementation(libs.androidx.cardview)
implementation(libs.retrofit)
implementation(libs.retrofit.gson)
implementation(libs.coil)
尝试点击 "Sync Now",一堆报错。
尝试点击第44行的代码“ implementation(libs.androidx.core.ktx)”按住Ctrl+左键单击(Mac是CMD+左键单击),点击进入后发现是
第13行:
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
然后version.ref = "coreKtx"依赖
第3行的coreKtx = "1.10.1"
依葫芦画瓢: 在[versions]中添加:
lifecycle = "2.7.0"
recyclerview = "1.3.2"
cardview = "1.0.0"
retrofit = "2.9.0"
coil = "2.5.0"
在[libraries]中添加:
androidx-lifecycle-viewmodel = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-ktx", version.ref = "lifecycle" }
androidx-lifecycle-livedata = { group = "androidx.lifecycle", name = "lifecycle-livedata-ktx", version.ref = "lifecycle" }
androidx-recyclerview = { group = "androidx.recyclerview", name = "recyclerview", version.ref = "recyclerview" }
androidx-cardview = { group = "androidx.cardview", name = "cardview", version.ref = "cardview" }
retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
retrofit-gson = { group = "com.squareup.retrofit2", name = "converter-gson", version.ref = "retrofit" }
coil = { group = "io.coil-kt", name = "coil", version.ref = "coil" }
完整代码:
build.gradle.kts文件:
dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.appcompat)
implementation(libs.material)
implementation(libs.androidx.activity)
implementation(libs.androidx.constraintlayout)
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
implementation(libs.androidx.lifecycle.viewmodel)
implementation(libs.androidx.lifecycle.livedata)
implementation(libs.androidx.recyclerview)
implementation(libs.androidx.cardview)
implementation(libs.retrofit)
implementation(libs.retrofit.gson)
implementation(libs.coil)
}
build.gradle.kts文件:
[versions]
agp = "9.1.0"
coreKtx = "1.10.1"
junit = "4.13.2"
junitVersion = "1.1.5"
espressoCore = "3.5.1"
appcompat = "1.6.1"
material = "1.10.0"
activity = "1.8.0"
constraintlayout = "2.1.4"
lifecycle = "2.7.0"
recyclerview = "1.3.2"
cardview = "1.0.0"
retrofit = "2.9.0"
coil = "2.5.0"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
material = { group = "com.google.android.material", name = "material", version.ref = "material" }
androidx-activity = { group = "androidx.activity", name = "activity", version.ref = "activity" }
androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" }
androidx-lifecycle-viewmodel = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-ktx", version.ref = "lifecycle" }
androidx-lifecycle-livedata = { group = "androidx.lifecycle", name = "lifecycle-livedata-ktx", version.ref = "lifecycle" }
androidx-recyclerview = { group = "androidx.recyclerview", name = "recyclerview", version.ref = "recyclerview" }
androidx-cardview = { group = "androidx.cardview", name = "cardview", version.ref = "cardview" }
retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
retrofit-gson = { group = "com.squareup.retrofit2", name = "converter-gson", version.ref = "retrofit" }
coil = { group = "io.coil-kt", name = "coil", version.ref = "coil" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
然后"Sync Now",就此,我们就完成了协程,列表,卡片,网络,网络解析,图片库这些依赖的安装,为接下来的MVVM提供最基础的支撑。
三、MVVM,View,以及两者的绑定
接着在模块级build.gradle.kts(Module:app)中配置viewBinding如下图所示:
新建包名viewModel如图:
3.1 新建一个ViewModel文件
命名为UserViewModel.kts:
以后所有的类,文件都是从androidX中引用:
接着先打开www.baidu.com. 然后打开手机模式,接着截张列表的图片
然后使用 通义灵码智能体,把图片复制到聊天框,输入提示语:“根据图片帮我写一个kotlin的model类,生成一个可以在协程中使用的方法,并返回二十条数据的数组,图片要用真实的网络图片,可以直接显示的”
3.2 生成NewsRepository.kts的model文件:
package com.ht.mvvmdemo
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/**
* 新闻数据仓库
*/
class NewsRepository {
/**
* 获取新闻列表(协程中使用)
* @return 新闻文章数组
*/
suspend fun getNewsList(): Array<NewsArticle> = withContext(Dispatchers.IO) {
// 模拟从网络或数据库获取数据
arrayOf(
NewsArticle(
id = "1",
title = "不靠剧场、不拼制作,这家北京企业把戏剧玩成消费新风口",
coverImage = "https://picsum.photos/seed/news1/400/300.jpg",
source = "京报网",
publishTime = System.currentTimeMillis() - 3600000
),
NewsArticle(
id = "2",
title = "抗衰老最佳睡眠时长出炉!50万人研究:你可能刚好睡在...",
coverImage = "https://picsum.photos/seed/news2/400/300.jpg",
source = "长城网",
publishTime = System.currentTimeMillis() - 7200000
),
NewsArticle(
id = "3",
title = "82岁大爷到银行存款,170万元竟全是练功券;警方:老人...",
coverImage = "https://picsum.photos/seed/news3/400/300.jpg",
source = "中原网",
publishTime = System.currentTimeMillis() - 10800000
),
NewsArticle(
id = "4",
title = "2024年高考报名人数再创新高,教育部发布最新政策解读",
coverImage = "https://picsum.photos/seed/news4/400/300.jpg",
source = "新华网",
publishTime = System.currentTimeMillis() - 14400000
),
NewsArticle(
id = "5",
title = "新能源汽车销量突破千万辆,行业迎来发展新机遇",
coverImage = "https://picsum.photos/seed/news5/400/300.jpg",
source = "经济日报",
publishTime = System.currentTimeMillis() - 18000000
),
NewsArticle(
id = "6",
title = "科学家发现新型量子材料,有望推动量子计算重大突破",
coverImage = "https://picsum.photos/seed/news6/400/300.jpg",
source = "科技日报",
publishTime = System.currentTimeMillis() - 21600000
),
NewsArticle(
id = "7",
title = "全国多地迎来强降雨,气象部门发布预警提醒",
coverImage = "https://picsum.photos/seed/news7/400/300.jpg",
source = "中国天气网",
publishTime = System.currentTimeMillis() - 25200000
),
NewsArticle(
id = "8",
title = "故宫博物院推出新展览,珍贵文物首次集中亮相",
coverImage = "https://picsum.photos/seed/news8/400/300.jpg",
source = "文旅中国",
publishTime = System.currentTimeMillis() - 28800000
),
NewsArticle(
id = "9",
title = "人工智能辅助医疗诊断准确率超95%,临床应用前景广阔",
coverImage = "https://picsum.photos/seed/news9/400/300.jpg",
source = "健康报",
publishTime = System.currentTimeMillis() - 32400000
),
NewsArticle(
id = "10",
title = "城市马拉松赛事火爆,全民健身热潮持续升温",
coverImage = "https://picsum.photos/seed/news10/400/300.jpg",
source = "体育报",
publishTime = System.currentTimeMillis() - 36000000
),
NewsArticle(
id = "11",
title = "农村电商蓬勃发展,助力乡村振兴新路径",
coverImage = "https://picsum.photos/seed/news11/400/300.jpg",
source = "农民日报",
publishTime = System.currentTimeMillis() - 39600000
),
NewsArticle(
id = "12",
title = "5G网络覆盖率达98%,数字经济迎来爆发式增长",
coverImage = "https://picsum.photos/seed/news12/400/300.jpg",
source = "通信世界",
publishTime = System.currentTimeMillis() - 43200000
),
NewsArticle(
id = "13",
title = "传统文化进校园,非遗传承从娃娃抓起",
coverImage = "https://picsum.photos/seed/news13/400/300.jpg",
source = "教育报",
publishTime = System.currentTimeMillis() - 46800000
),
NewsArticle(
id = "14",
title = "航天工程再获突破,空间站建设迈出关键一步",
coverImage = "https://picsum.photos/seed/news14/400/300.jpg",
source = "航天科技",
publishTime = System.currentTimeMillis() - 50400000
),
NewsArticle(
id = "15",
title = "绿色金融助力低碳转型,碳中和目标加速推进",
coverImage = "https://picsum.photos/seed/news15/400/300.jpg",
source = "金融时报",
publishTime = System.currentTimeMillis() - 54000000
),
NewsArticle(
id = "16",
title = "智慧城市建设成效显著,市民生活更加便捷",
coverImage = "https://picsum.photos/seed/news16/400/300.jpg",
source = "城市报",
publishTime = System.currentTimeMillis() - 57600000
),
NewsArticle(
id = "17",
title = "文化遗产保护新举措,千年古镇焕发新生机",
coverImage = "https://picsum.photos/seed/news17/400/300.jpg",
source = "文化遗产报",
publishTime = System.currentTimeMillis() - 61200000
),
NewsArticle(
id = "18",
title = "数字经济赋能传统产业,转型升级成果丰硕",
coverImage = "https://picsum.photos/seed/news18/400/300.jpg",
source = "产业经济报",
publishTime = System.currentTimeMillis() - 64800000
),
NewsArticle(
id = "19",
title = "国际文化交流活动丰富多彩,中华文化走向世界",
coverImage = "https://picsum.photos/seed/news19/400/300.jpg",
source = "国际在线",
publishTime = System.currentTimeMillis() - 68400000
),
NewsArticle(
id = "20",
title = "生态文明建设成效显著,绿水青山就是金山银山",
coverImage = "https://picsum.photos/seed/news20/400/300.jpg",
source = "环境报",
publishTime = System.currentTimeMillis() - 72000000
)
)
}
}
注意:网络请求为异步,如果需要在协程中使用函数,函数必须声明为suspend(这是规范)。 类似于 async await必须修饰Promise函数,return new Promise((resolve,reject)=> return resolve("abc")
一样的意思
3.3 生成 Model文件:
NewsArticle:
package com.ht.mvvmdemo
/**
* 新闻文章数据模型
*/
data class NewsArticle(
val id: String, // 文章ID
val title: String, // 文章标题
val coverImage: String, // 封面图片URL
val source: String, // 来源(如:京报网、长城网、中原网)
val publishTime: Long = 0, // 发布时间(时间戳)
val content: String = "", // 文章内容
val author: String = "" // 作者
)
3.4 ViewModel如何使用Model中的数据
package com.ht.mvvmdemo.viewModel
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.ht.mvvmdemo.NewsArticle
import com.ht.mvvmdemo.NewsRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class UserViewModel: ViewModel() {
private val model = NewsRepository()
private val _news = MutableLiveData<Array<NewsArticle>>()
val news = _news
fun getNews(){
viewModelScope.launch {
// 异步网络请求 data为 Array<NewsArticle>
val data = withContext(Dispatchers.IO){
model.getNewsList()
}
// 回到主线程,当时最好是使用
// withContext(Dispatchers.Main.immediate){
// news.value = data
// }
news.value = data
}
}
}
3.5 设置view和viewmodel的绑定
private val userModel = UserViewModel()
初始化binding
package com.ht.mvvmdemo
import android.os.Bundle
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import com.ht.mvvmdemo.databinding.ActivityMainBinding
import com.ht.mvvmdemo.viewModel.UserViewModel
class MainActivity : AppCompatActivity() {
// 这是延迟初始化,延迟初始化是在类被创建时才初始化变量,而不是在变量被使用的时候才初始化变量
// ActivityMainBinding viewbing内部自动将layout文件绑定了一个驼峰命名的Binding类,比如该项目中的activity_main.xml自动绑定ActivityMainBinding类
private lateinit var binding: ActivityMainBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
initView()
// setContentView(R.layout.activity_main)
// ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main)) { v, insets ->
// val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
// v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom)
// insets
// }
}
fun initView() {
// 没有初始化,后续用binding会直接报错
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
// viewmodel livedata的setvalue ,postvalue更新后,监测到数据更新,再通过adapter更新数据源的流程,实现了viewmodel向view的通信
viewModel.news.observe(this) { newsList -> adapter.updateData(newsList.toList()) }
// view向viewmodel通信
viewModel.getNews()
}
}
3.6 设置Adapter,ViewHolder对应的卡片xml
item_news.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.cardview.widget.CardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="8dp"
app:cardCornerRadius="8dp"
app:cardElevation="4dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:padding="12dp">
<ImageView
android:id="@+id/iv_cover"
android:layout_width="120dp"
android:layout_height="90dp"
android:scaleType="centerCrop"
android:contentDescription="@string/app_name" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/tv_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:maxLines="2"
android:textColor="#333333"
android:textSize="16sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tv_source"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="#999999"
android:textSize="12sp" />
</LinearLayout>
</LinearLayout>
</androidx.cardview.widget.CardView>
设置Adapter以及更新 暂未使用binding
package com.ht.mvvmdemo
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
import coil.load
class NewsAdapter(
private var newsList: List<NewsArticle> = emptyList(),
private val onItemClickListener: ((NewsArticle) -> Unit)? = null
) : RecyclerView.Adapter<NewsAdapter.NewsViewHolder>() {
class NewsViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
val ivCover: ImageView = itemView.findViewById(R.id.iv_cover)
val tvTitle: TextView = itemView.findViewById(R.id.tv_title)
val tvSource: TextView = itemView.findViewById(R.id.tv_source)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): NewsViewHolder {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.item_news, parent, false)
return NewsViewHolder(view)
}
override fun onBindViewHolder(holder: NewsViewHolder, position: Int) {
val news = newsList[position]
// 加载封面图片
holder.ivCover.load(news.coverImage) {
placeholder(R.drawable.ic_launcher_background)
error(R.drawable.ic_launcher_background)
crossfade(true)
}
// 设置标题和来源
holder.tvTitle.text = news.title
holder.tvSource.text = news.source
// 设置点击事件
holder.itemView.setOnClickListener {
onItemClickListener?.invoke(news)
}
}
override fun getItemCount(): Int {
Log.d("NewsAdapter", "ItemCount: ${newsList.size}")
return newsList.size
}
// 更新数据
fun updateData(newList: List<NewsArticle>) {
newsList = newList
notifyDataSetChanged()
}
}
3.7 设置recycleview及Adapter
adapter = NewsAdapter() // Initialize adapter with newsList value
binding.recyclerView.adapter = adapter
binding.recyclerView.layoutManager = LinearLayoutManager(this)
// viewmodel livedata的setvalue ,postvalue更新后,监测到数据更新,再通过adapter更新数据源的流程,实现了viewmodel向view的通信
viewModel.news.observe(this) { newsList -> adapter.updateData(newsList.toList()) }
// view向viewmodel通信
viewModel.getNews()
4. 权限配置:
android:usesCleartextTraffic="true">
以及
<uses-permission android:name="android.permission.INTERNET" />
最终效果:
如果用MutableStateFlow则需要协程collect收集改变