現在從事Android開發多少都要懂點架構知識,從MVC、MVP再到MVVM,想必大家對於其各自的優缺點早已如數家珍。今天介紹的MVI與MVVM非常接近,可以針對性地彌補MVVM中的一些缺陷
何為MVI?
MVI即Model-View-Intent,它受Cycle.js前端框架的啟發,提倡一種單向資料流的設計思想,非常適合資料驅動型的UI展示專案:
- Model: 與其他MVVM中的Model不同的是,MVI的Model主要指UI狀態(State)。當前介面展示的內容無非就是UI狀態的一個快照:例如資料載入過程、控制元件位置等都是一種UI狀態
- View: 與其他MVX中的View一致,可能是一個Activity、Fragment或者任意UI承載單元。MVI中的View通過訂閱Intent的變化實現介面重新整理(不是Activity的Intent、後面介紹)
- Intent: 此Intent不是Activity的Intent,使用者的任何操作都被包裝成Intent後傳送給Model進行資料請求
單向資料流
使用者操作以Intent的形式通知Model => Model基於Intent更新State => View接收到State變化重新整理UI。
資料永遠在一個環形結構中單向流動,不能反向流動: 這種單向資料流結構的MVI有什麼優缺點呢?
-
優點
- UI的所有變化來自State,所以只需聚焦State,架構更簡單、易於除錯
- 資料單向流動,很容易對狀態變化進行跟蹤和回溯
- state例項都是不可變的,確保執行緒安全
- UI只是反應State的變化,沒有額外邏輯,可以被輕鬆替換或複用
-
缺點
- 所有的操作最終都會轉換成State,所以當複雜頁面的State容易膨脹
- state是不變的,每當state需要更新時都要建立新物件替代老物件,這會帶來一定記憶體開銷
- 有些事件類的UI變化不適合用state描述,例如彈出一個toast或者snackbar
talk is cheap, show me the code。
我們通過一個Sample看一下如何快速搭建一個MVI架構的專案。
程式碼示例
程式碼結構如下:
Sample中的依賴庫
// Added Dependencies
implementation "androidx.recyclerview:recyclerview:1.1.0"
implementation 'android.arch.lifecycle:extensions:1.1.1'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.2.0'
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.2.0'
implementation 'com.github.bumptech.glide:glide:4.11.0'
//retrofit
implementation 'com.squareup.retrofit2:retrofit:2.8.1'
implementation "com.squareup.retrofit2:converter-moshi:2.6.2"
//Coroutine
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.6"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.6"
複製程式碼
程式碼中使用以下API進行請求
https://reqres.in/api/users
複製程式碼
將得到結果:
1. 資料層
1.1 User
定義User
的data class
package com.my.mvi.data.model
data class User(
@Json(name = "id")
val id: Int = 0,
@Json(name = "first_name")
val name: String = "",
@Json(name = "email")
val email: String = "",
@Json(name = "avator")
val avator: String = ""
)
複製程式碼
1.2 ApiService
定義ApiService,getUsers
方法進行資料請求
package com.my.mvi.data.api
interface ApiService {
@GET("users")
suspend fun getUsers(): List<User>
}
複製程式碼
1.3 Retrofit
建立Retrofit例項
object RetrofitBuilder {
private const val BASE_URL = "http://reqres.in/api/user/1"
private fun getRetrofit() = Retrofit.Builder()
.baseUrl(BASE_URL)
.addConverterFactory(MoshiConverterFactory.create())
.build()
val apiService: ApiService = getRetrofit().create(ApiService::class.java)
}
複製程式碼
1.4 Repository
定義Repository,封裝API請求的具體實現
package com.my.mvi.data.repository
class MainRepository(private val apiService: ApiService) {
suspend fun getUsers() = apiService.getUsers()
}
複製程式碼
2. UI層
Model定義完畢後,開始定義UI層,包括View、ViewModel以及Intent的定義
2.1 RecyclerView.Adapter
首先,需要一個RecyclerView來呈現列表結果,定義MainAdapter如下:
package com.my.mvi.ui.main.adapter
class MainAdapter(
private val users: ArrayList<User>
) : RecyclerView.Adapter<MainAdapter.DataViewHolder>() {
class DataViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
fun bind(user: User) {
itemView.textViewUserName.text = user.name
itemView.textViewUserEmail.text = user.email
Glide.with(itemView.imageViewAvatar.context)
.load(user.avatar)
.into(itemView.imageViewAvatar)
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
DataViewHolder(
LayoutInflater.from(parent.context).inflate(
R.layout.item_layout, parent,
false
)
)
override fun getItemCount(): Int = users.size
override fun onBindViewHolder(holder: DataViewHolder, position: Int) =
holder.bind(users[position])
fun addData(list: List<User>) {
users.addAll(list)
}
}
複製程式碼
item_layout.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/container"
android:layout_width="match_parent"
android:layout_height="60dp">
<ImageView
android:id="@+id/imageViewAvatar"
android:layout_width="60dp"
android:layout_height="0dp"
android:padding="4dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<androidx.appcompat.widget.AppCompatTextView
android:id="@+id/textViewUserName"
style="@style/TextAppearance.AppCompat.Large"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginTop="4dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/imageViewAvatar"
app:layout_constraintTop_toTopOf="parent"/>
<androidx.appcompat.widget.AppCompatTextView
android:id="@+id/textViewUserEmail"
android:layout_width="0dp"
android:layout_height="wrap_content"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@+id/textViewUserName"
app:layout_constraintTop_toBottomOf="@+id/textViewUserName" />
</androidx.constraintlayout.widget.ConstraintLayout>
複製程式碼
2.2 Intent
定義Intent用來包裝使用者Action
package com.my.mvi.ui.main.intent
sealed class MainIntent {
object FetchUser : MainIntent()
}
複製程式碼
2.3 State
定義UI層的State結構體
sealed class MainState {
object Idle : MainState()
object Loading : MainState()
data class Users(val user: List<User>) : MainState()
data class Error(val error: String?) : MainState()
}
複製程式碼
2.4 ViewModel
ViewModel是MVI的核心,存放和管理State,同時接受Intent並進行資料請求
package com.my.mvi.ui.main.viewmodel
class MainViewModel(
private val repository: MainRepository
) : ViewModel() {
val userIntent = Channel<MainIntent>(Channel.UNLIMITED)
private val _state = MutableStateFlow<MainState>(MainState.Idle)
val state: StateFlow<MainState>
get() = _state
init {
handleIntent()
}
private fun handleIntent() {
viewModelScope.launch {
userIntent.consumeAsFlow().collect {
when (it) {
is MainIntent.FetchUser -> fetchUser()
}
}
}
}
private fun fetchUser() {
viewModelScope.launch {
_state.value = MainState.Loading
_state.value = try {
MainState.Users(repository.getUsers())
} catch (e: Exception) {
MainState.Error(e.localizedMessage)
}
}
}
}
複製程式碼
我們在handleIntent
中訂閱userIntent
並根據Action型別執行相應操作。本case中當出現FetchUser
的Action時,呼叫fetchUser
方法請求使用者資料。使用者資料返回後,會更新State,MainActivity
訂閱此State並重新整理介面。
2.5 ViewModelFactory
構造ViewModel需要Repository,所以通過ViewModelFactory注入必要的依賴
class ViewModelFactory(private val apiService: ApiService) : ViewModelProvider.Factory {
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
if (modelClass.isAssignableFrom(MainViewModel::class.java)) {
return MainViewModel(MainRepository(apiService)) as T
}
throw IllegalArgumentException("Unknown class name")
}
}
複製程式碼
2.6 定義MainActivity
package com.my.mvi.ui.main.view
class MainActivity : AppCompatActivity() {
private lateinit var mainViewModel: MainViewModel
private var adapter = MainAdapter(arrayListOf())
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
setupUI()
setupViewModel()
observeViewModel()
setupClicks()
}
private fun setupClicks() {
buttonFetchUser.setOnClickListener {
lifecycleScope.launch {
mainViewModel.userIntent.send(MainIntent.FetchUser)
}
}
}
private fun setupUI() {
recyclerView.layoutManager = LinearLayoutManager(this)
recyclerView.run {
addItemDecoration(
DividerItemDecoration(
recyclerView.context,
(recyclerView.layoutManager as LinearLayoutManager).orientation
)
)
}
recyclerView.adapter = adapter
}
private fun setupViewModel() {
mainViewModel = ViewModelProviders.of(
this,
ViewModelFactory(
ApiHelperImpl(
RetrofitBuilder.apiService
)
)
).get(MainViewModel::class.java)
}
private fun observeViewModel() {
lifecycleScope.launch {
mainViewModel.state.collect {
when (it) {
is MainState.Idle -> {
}
is MainState.Loading -> {
buttonFetchUser.visibility = View.GONE
progressBar.visibility = View.VISIBLE
}
is MainState.Users -> {
progressBar.visibility = View.GONE
buttonFetchUser.visibility = View.GONE
renderList(it.user)
}
is MainState.Error -> {
progressBar.visibility = View.GONE
buttonFetchUser.visibility = View.VISIBLE
Toast.makeText(this@MainActivity, it.error, Toast.LENGTH_LONG).show()
}
}
}
}
}
private fun renderList(users: List<User>) {
recyclerView.visibility = View.VISIBLE
users.let { listOfUsers -> listOfUsers.let { adapter.addData(it) } }
adapter.notifyDataSetChanged()
}
}
複製程式碼
MainActivity中訂閱mainViewModel.state
,根據State處理各種UI顯示和重新整理。
activity_main.xml:
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".ui.main.view.MainActivity">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:visibility="gone" />
<ProgressBar
android:id="@+id/progressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
android:visibility="gone"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<Button
android:id="@+id/buttonFetchUser"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/fetch_user"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
複製程式碼
如上,一個完整的MVI專案就完成了。
最後
MVI在MVVM的基礎上,規定了資料的單向流動和狀態的不可變性,這類似於前端的Redux思想,非常適合UI展示類的場景。MVVM也好,MVI也好都不是架構的最終形態,世界上沒有完美的架構,要根據專案情況選擇適合的架構進行開發。