有一段时间没有去写过框架了,最近新的框架MVI,其实出来有一段时间了,只不过大部分项目还没有切换过去,对于公司的老项目来说,之前的MVC、MVP也能用,没有替换的必要,而对于新建的项目来说还是可以替换成功MVVM、MVI等框架的。本文完成后的效果图:
每当一个新的框架出来,都会解决掉上一个框架所存在的问题,但同时也会产生新的问题,瑕不掩瑜,可以在实际开发中,解决掉产生的问题,就能够更好的使用框架,那么MVI解决了MVVM的什么问题呢?
(资料图)
MVI同样是基于观察者模式,只不过数据通信方面是单向的,解决了MVVM双向通信所带来的问题,实际上MVVM也能做成单向通讯,但是这样就不是纯粹的MVVM,当然了,仁者见仁,智者见智。MVI框架适用于UI变化很多的项目,通过数据去驱动UI,MVI就是Model、View、Intent。
Model 这里的Model有所不同,里面还包含UI的状态。View 还是视图,例如Activity、Fragment等。Intent 意图,这个和Activity的意图要区分开,我觉得说成是行为可能更妥当,表示去做什么。多说无益,我们还是进入实操环节吧。
首先创建一个名为MviDemo的项目
项目创建好了,下面我们需要先进行项目的基本配置。
文章中会通过一个网络API接口,拿到数据来进行MVI框架的搭建与使用,接口地址如下:
http://service.picasso.adesk.com/v1/vertical/vertical?limit=30&skip=180&adult=false&first=0&order=hot
通过浏览器打开可以得到很多数据,如图所示:
这些数据都是JSON格式的,后面我们还会用到这些数据。因为接口使用的是http,而不是https,所以在xml文件夹下新建一个network_security_config.xml,代码如下:
然后在AndroidManifest.xml中的application标签中配置它,如图所示:
从Android 9.0起,默认使用https进行网络访问,如果要进行http访问则需要添加这个配置。还需要添加一个网络访问静态权限:
添加位置如下图所示:
项目正常搭建还需要一些依赖库和其他的一些设置,下面我们配置app模块下的build.gradle。
请注意,这里是配置app的build.gradle,而不是项目的build.gradle,很多人会配置错误,所以我再次强调一下,将你的项目切换到Android模式,如下图所示:
这里我标注了一下,你看到有两个build.gradle文件,两个文件的后面有灰色的文字说明,就很清楚的知道这两个build.gradle分别是项目和模块的。下面打开app模块下的build.gradle,在里面找到dependencies{}
闭包,闭包中添加如下依赖:
// lifecycle implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.4.1" implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.4.1" //glide implementation "com.github.bumptech.glide:glide:4.14.2" //retrofit implementation "com.squareup.retrofit2:retrofit:2.9.0" //retrofit moshi implementation "com.squareup.retrofit2:converter-moshi:2.6.2" //moshi used KotlinJsonAdapterFactory implementation "com.squareup.moshi:moshi-kotlin:1.9.3" //Coroutine implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.1" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.1"
添加位置如下图所示:
然后再打开viewBinding,在android{}闭包下添加如下代码:
buildFeatures { viewBinding true }
添加位置如下图所示:
添加之后你会看到右上角有一个Sync Now,点击它进行依赖的载入配置,配置好之后进入下一步,为了确保你的项目没有问题,你可以现在运行一下看看。
当我们使用Kotlin时,网络访问就变得更简单了,只需要Retrofit和协程即可,首先我们在com.llw.mvidemo
包下新建一个data
包,然后在data
包下新建一个model
包,model
包下我们可以通过刚才使用网页访问API拿到的JSON数据来生成一个数据类。
生成数据类,这里我们可以使用一个插件,搜索JSON To Kotlin Class,如下图所示:
下载安装之后,如果需要重启,你就重启AS,重启之后,右键点击model → New → Kotlin data class File from JSON,如图所示:
在出现的弹窗中复制通过网页请求得到的JSON数据字符串,如图所示:
这里如果觉得看起来不舒服,点击 Format 进行JSON数据格式化,然后我们需要设置数据类的名称,这里输入Wallpaper,因为我们需要使用Moshi,将JSON数据直接转成数据类,所以这里我们点击Advanced,如图所示:
这里默认是None,选择MoShi(Reflect),其他的不用更改,点击OK,此弹窗关闭,回到之前的弹窗,然后点击 Generate生成数据类,你会发现有三个数据类,分别是Wallpaper、Res和Vertical,我们看一下Wallpaper的代码:
package com.llw.mvidemo.data.modelimport com.squareup.moshi.Jsondata class Wallpaper( @Json(name = "code") val code: Int, @Json(name = "msg") val msg: String, @Json(name = "res") val res: Res)
这里每一个字段上都有一个@Json
注解,这里是MoShi依赖库的注解,主要检查一下导包的问题,这里还有一个小故事,Google 的Gson库,算是推出比较早的,从事Gson库的开发人员,后面离职去了Square,也就是OkHttp、Retrofit的开发者。Retrofit一开始是支持Gson转换的,后面增加了MoShi的转换,Moshi拥有出色的Kotlin支持以及编译时代码生成功能,可以使应用程序更快更小。这个故事我也是听说的,你可以自己去求证,下面继续。
现在数据类有了,那么我们就需要根据这个数据类来写一个接口类,在com.llw.mvidemo
包下新建一个network
包,network
包下创建一个接口类ApiService
,代码如下所示:
interface ApiService { /** * 获取壁纸 */ @GET("v1/vertical/vertical?limit=30&skip=180&adult=false&first=0&order=hot") suspend fun getWallPaper(): Wallpaper}
这里属于Retrofit的使用方式,增加了协程的使用而已,就取代了RxJava的线程调度。
现在有接口,下面我们来做网络请求,在network
包下新建一个NetworkUtils
类,代码如下:
package com.llw.mvidemo.networkimport com.squareup.moshi.Moshiimport com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactoryimport retrofit2.Retrofitimport retrofit2.converter.moshi.MoshiConverterFactory/** * 网络工具类 */object NetworkUtils { private const val BASE_URL = "http://service.picasso.adesk.com/" /** * 通过Moshi 将JSON转为为 Kotlin 的Data class */ private val moshi: Moshi = Moshi.Builder() .add(KotlinJsonAdapterFactory()) .build() /** * 构建Retrofit */ private fun getRetrofit() = Retrofit.Builder() .baseUrl(BASE_URL) .addConverterFactory(MoshiConverterFactory.create(moshi)) .build() /** * 创建Api网络请求服务 */ val apiService: ApiService = getRetrofit().create(ApiService::class.java)}
由于担心你看的时候导错包,现在贴代码我会将导包的信息也贴出来,这样你总不会再导错包了吧。下面简单说明一下这个类,首先我定义了一个常量BASE_URL。作为网络接口请求的地址头,然后构建了MoShi,通过MoShi去进行JSON转Kotlin数据类的处理,之后就是构建Retrofit,将MoShi设置进去,最后就是通过Retrofit创建一个网络请求服务。
之前我们说MVI的I 是Intent,表示意图或行为,和ViewModel一样,我们在使用Intent的时候,也是一个Intent对应一个Activity/Fragment。
在data
包下创建一个intent
包,intent
包下新建一个MainIntent
类,代码如下所示:
package com.llw.mvidemo.data.intent/** * 页面意图 */sealed class MainIntent { /** * 获取壁纸 */ object GetWallpaper : MainIntent()}
这里只有一个GetWallpaper,表示获取壁纸的动作,你还可以添加其他的,例如保存图片、下载图片等,现在意图有了,下面来创建状态,一个意图有用多个状态。
在data
包下创建一个state
包,state
包下新建一个MainState
类,代码如下:
package com.llw.mvidemo.data.stateimport com.llw.mvidemo.data.model.Wallpaper/** * 页面状态 */sealed class MainState { /** * 空闲 */ object Idle : MainState() /** * 加载 */ object Loading : MainState() /** * 获取壁纸 */ data class Wallpapers(val wallpaper: Wallpaper) : MainState() /** * 错误信息 */ data class Error(val error: String) : MainState()}
这里可以看到四个状态,获取壁纸属于其中的一个状态,通过状态可以去更改页面中的UI,后面我们会看到这一点,这里的状态你还可以再进行细分,例如每一个网络请求你可以增加一个请求中、请求成功、请求失败。
在MVI模式中,ViewModel的重要性又提高了,不过我们同样要添加Repository,作为数据存储库。
在data
包下创建一个repository
包,repository
包下新建一个MainRepository
类,代码如下:
package com.llw.mvidemo.data.repositoryimport com.llw.mvidemo.network.ApiService/** * 数据存储库 */class MainRepository(private val apiService: ApiService) { /** * 获取壁纸 */ suspend fun getWallPaper() = apiService.getWallPaper()}
这里的代码就没什么好说的,下面我们写ViewModel,和MVVM模式中没什么两样的。
下面在com.llw.mvidemo
包下新建一个ui
包,ui
包下新建一个adapter
包,adapter
包下新建一个MainViewModel
类,代码如下:
package com.llw.mvidemo.ui.viewmodelimport androidx.lifecycle.ViewModelimport androidx.lifecycle.viewModelScopeimport com.llw.mvidemo.data.repository.MainRepositoryimport com.llw.mvidemo.data.intent.MainIntentimport com.llw.mvidemo.data.state.MainStateimport kotlinx.coroutines.channels.Channelimport kotlinx.coroutines.flow.MutableStateFlowimport kotlinx.coroutines.flow.StateFlowimport kotlinx.coroutines.flow.consumeAsFlowimport kotlinx.coroutines.launch/** * @link MainActivity */class MainViewModel(private val repository: MainRepository) : ViewModel() { //创建意图管道,容量无限大 val mainIntentChannel = Channel(Channel.UNLIMITED) //可变状态数据流 private val _state = MutableStateFlow(MainState.Idle) //可观察状态数据流 val state: StateFlow get() = _state init { viewModelScope.launch { //收集意图 mainIntentChannel.consumeAsFlow().collect { when (it) { //发现意图为获取壁纸 is MainIntent.GetWallpaper -> getWallpaper() } } } } /** * 获取壁纸 */ private fun getWallpaper() { viewModelScope.launch { //修改状态为加载中 _state.value = MainState.Loading //网络请求状态 _state.value = try { //请求成功 MainState.Wallpapers(repository.getWallPaper()) } catch (e: Exception) { //请求失败 MainState.Error(e.localizedMessage ?: "UnKnown Error") } } }}
这里首先创建一个意图管道,然后是一个可变的状态数据流和一个不可变观察状态数据流,观察者模式。在初始化的时候就进行意图的收集,你可以理解为监听,当收集到目标意图MainIntent.GetWallpaper
时就进行相应的意图处理,调用getWallpaper()
函数,这里面修改可变的状态_state
,而当_state
发生变化,state
就观察到了,就会进行相应的动作,这个通过是在View中进行,也就是Activity/Fragment中进行。这里对_state
首先赋值为Loading
,表示加载中,然后进行一个网络请求,结果就是成功或者失败,如果成功,则赋值Wallpapers
,View中收集到这个状态后就可以进行页面数据的渲染了,请求失败,也要更改状态。
在viewmodel包下新建一个ViewModelFactory类,代码如下:
package com.llw.mvidemo.ui.viewmodelimport androidx.lifecycle.ViewModelimport androidx.lifecycle.ViewModelProviderimport com.llw.mvidemo.network.ApiServiceimport com.llw.mvidemo.data.repository.MainRepository/** * ViewModel工厂 */class ViewModelFactory(private val apiService: ApiService) : ViewModelProvider.Factory { override fun create(modelClass: Class): T { // 判断 MainViewModel 是不是 modelClass 的父类或接口 if (modelClass.isAssignableFrom(MainViewModel::class.java)) { return MainViewModel(MainRepository(apiService)) as T } throw IllegalArgumentException("UnKnown class") }}
前面我们写好基本的框架内容,下面来进行使用,简单来说,请求数据然后渲染出来,因为这里请求的是壁纸数据,所以我需要写一个适配器。
在创建适配器之前首先我们需要创建一个适配器所对应的item布局,在layout下新建一个item_wallpaper_rv.xml
,代码如下图所示:
这里使用了ShapeableImageView,这个控件的优势就在于可以自己设置圆角,在themes.xml中添加如下代码:
添加位置如下图所示:
下面进行我们在ui包下新建一个adapter
包,adapter
包下新建一个WallpaperAdapter
类,里面的代码如下所示:
package com.llw.mvidemo.ui.adapterimport android.view.LayoutInflaterimport android.view.ViewGroupimport androidx.recyclerview.widget.RecyclerViewimport com.bumptech.glide.Glideimport com.llw.mvidemo.data.model.Verticalimport com.llw.mvidemo.databinding.ItemWallpaperRvBinding/** * 壁纸适配器 */class WallpaperAdapter(private val verticals: ArrayList) : RecyclerView.Adapter() { fun addData(data: List) { verticals.addAll(data) } class ViewHolder(itemWallPaperRvBinding: ItemWallpaperRvBinding) : RecyclerView.ViewHolder(itemWallPaperRvBinding.root) { var binding: ItemWallpaperRvBinding init { binding = itemWallPaperRvBinding } } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = ViewHolder(ItemWallpaperRvBinding.inflate(LayoutInflater.from(parent.context), parent, false)) override fun getItemCount() = verticals.size override fun onBindViewHolder(holder: ViewHolder, position: Int) { //加载图片 verticals[position].img.let { Glide.with(holder.itemView.context).load(it).into(holder.binding.ivWallPaper) } }}
这里的代码相对比较简单,就不做说明了,属于适配器的基本操作了。
适配器写好之后,我们需要修改一下activity_main.xml
中的内容,修改后代码如下所示:
下面我们进入MainActivity
,修改里面的代码如下所示:
package com.llw.mvidemo.uiimport androidx.appcompat.app.AppCompatActivityimport android.os.Bundleimport android.util.Logimport android.view.Viewimport android.widget.Toastimport androidx.lifecycle.ViewModelProviderimport androidx.lifecycle.lifecycleScopeimport androidx.recyclerview.widget.GridLayoutManagerimport com.llw.mvidemo.network.NetworkUtilsimport com.llw.mvidemo.databinding.ActivityMainBindingimport com.llw.mvidemo.data.intent.MainIntentimport com.llw.mvidemo.data.state.MainStateimport com.llw.mvidemo.ui.adapter.WallpaperAdapterimport com.llw.mvidemo.ui.viewmodel.MainViewModelimport com.llw.mvidemo.ui.viewmodel.ViewModelFactoryimport kotlinx.coroutines.launchclass MainActivity : AppCompatActivity() { private lateinit var binding: ActivityMainBinding private lateinit var mainViewModel: MainViewModel private var wallPaperAdapter = WallpaperAdapter(arrayListOf()) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) //使用ViewBinding binding = ActivityMainBinding.inflate(layoutInflater) setContentView(binding.root) //绑定ViewModel mainViewModel = ViewModelProvider(this, ViewModelFactory(NetworkUtils.apiService))[MainViewModel::class.java] //初始化 initView() //观察ViewModel observeViewModel() } /** * 观察ViewModel */ private fun observeViewModel() { lifecycleScope.launch { //状态收集 mainViewModel.state.collect { when(it) { is MainState.Idle -> { } is MainState.Loading -> { binding.btnGetWallpaper.visibility = View.GONE binding.pbLoading.visibility = View.VISIBLE } is MainState.Wallpapers -> { //数据返回 binding.btnGetWallpaper.visibility = View.GONE binding.pbLoading.visibility = View.GONE binding.rvWallpaper.visibility = View.VISIBLE it.wallpaper.let { paper -> wallPaperAdapter.addData(paper.res.vertical) } wallPaperAdapter.notifyDataSetChanged() } is MainState.Error -> { binding.pbLoading.visibility = View.GONE binding.btnGetWallpaper.visibility = View.VISIBLE Log.d("TAG", "observeViewModel: $it.error") Toast.makeText(this@MainActivity, it.error, Toast.LENGTH_LONG).show() } } } } } /** * 初始化 */ private fun initView() { //RV配置 binding.rvWallpaper.apply { layoutManager = GridLayoutManager(this@MainActivity, 2) adapter = wallPaperAdapter } //按钮点击 binding.btnGetWallpaper.setOnClickListener { lifecycleScope.launch{ //发送意图 mainViewModel.mainIntentChannel.send(MainIntent.GetWallpaper) } } }}
说明一下,首先声明变量并在onCreate()
中进行初始化,这里绑定ViewModel
采用的是ViewModelProvider()
,而不是ViewModelProviders.of
,这是因为这个API已经被移除了,在之前的版本中是过时弃用,在最新的版本中你都找不到这个API了,所以使用ViewModelProvider()
,然后通过ViewModelFactory
去创建对应的MainViewModel
。
initView()
函数中是控件的一些配置,比如给RecyclerView添加布局管理器和设置适配器,给按钮添加点击事件,在点击的时候发送意图,发送的意图被MainViewModel中mainIntentChannel
收集到,然后执行网络请求操作,此时意图的状态为Loading
。
observeViewModel()
函数中是对状态的收集,在状态为Loading
,隐藏按钮,显示加载条,然后网络请求会有结果,如果是成功,则在UI上隐藏按钮和加载条,显示列表控件,并添加数据到适配器中,然后刷新适配器,数据就会渲染出来;如果是失败则显示按钮,隐藏加载条,打印错误信息并提示一下。这样就完成了通过状态更新UI的环节,MVI的框架就是这样设计的。
页面UI(点击事件发送意图) → ViewModel收集意图(确定内容) →ViewModel更新状态(修改_state) → 页面观察ViewModel状态(收集state,执行相关的UI)
这是一个环,从UI页面出发,最终回到UI页面中进行数据渲染,我们看看效果。
源码地址:MviDemo