今天來將架構由目前的 MVP 調整成 MVVM,並且搭配 LiveData 使用。MVVM 是由 Model、View、ViewModel 組成,Model 和 View 的工作內容與 MVP 相同,所以我們待會將重點放在 ViewModel,以下是三者的負責內容 :
Model
: 負責管理和操作資料,包含資料庫的操作、SharedPreference、API 請求等等。View
: 這裡的 View 是指 res/layout.xml、Activity、Fragment,負責顯示和更新畫面,並通知使用者的操作給 ViewModel。ViewModel
: 屬於Android Jetpack 裡的 Lifecycle 類,是 Model 和 View 之間的橋樑,主要是以注重生命週期的方式儲存和管理與介面相關的資料,這樣就能將資料從 UI 分離出來。需要注意,ViewModel 本身不應該直接取得資料,而是透過操作相關的組件像是 Repository 來取得資料,將取得資料的任務分離出去,不是自行處理,最後再將資料提供給 View 去更新。
在原先的 MVP 中,資料的持有者是 Activity,也就是說,當 Activity 的生命週期改變時會影響資料是否消失於畫面上。最常見的例子就是當螢幕旋轉時,我們的 Activity 會被銷毀,然後再重新建立。這時就會發現原本在畫面上的資料不見了,因為隨著 Activity 被銷毀,它所持有的資料也會跟著銷毀,而畫面是與資料綁定的,所以畫面上才會沒有顯示資料。
ViewModel 的優勢在於開發者不再需要為了這類型的問題或是暫時存放於記憶體的資料被清除的情況而特地處理,只要透過 ViewModel 就行了,因為它的生命週期比 Activity 還長,所以可以在螢幕旋轉時將資料繼續保存。
上面有提到我們會搭配 LiveData
使用,那它有什麼特別的呢? LiveData
也是屬於 [androidx.lifecycle](https://developer.android.com/reference/androidx/lifecycle/package-summary?hl=zh-cn)
所提供的生命週期 library 的一員,是一種可感知生命週期的物件,實作了觀察者模式。透過感知 Activity 或是 Fragment 的生命週期,確保只會將更新後的資料通知給處於活躍狀態 (STARTED
、RESUMED
) 的觀察者。
它有兩種類型 :
來看看兩者搭配起來的基本用法,想看細節點這邊 :
class MyViewModel : ViewModel() {
private val users: MutableLiveData<List<User>> by lazy {
MutableLiveData<List<User>>().also {
loadUsers()
}
}
fun getUsers(): LiveData<List<User>> {
return users
}
private fun loadUsers() {
// Do an asynchronous operation to fetch users.
}
}
上面是自定義的 MyViewModel 並繼承於 ViewModel,來稍微讀一下內容 :
users
: 是一個私有的屬性,資料的型別為 MutableLiveData<List>,是一個可變動的 LiveData。by lazy
: 作用是延遲初始化,表示不是在建立對象時立刻會被初始化。MutableLiveData<List<User>>()
: 用於初始化 users 屬性,會建立新的 MutableLiveData 物件來儲存 User 列表。also { loadUsers() }
: alse
是 Kotlin 用來建立物件時負責執行附加操作的函式。這邊是用來執行 loadUsers()。getUsers()
: 是一個提供給外部使用的方法,會回傳 users 屬性。loadUsers()
: 在這邊是用來非同步執行取得 users 資料接著是在 Activity 使用 ViewModel :
class MyActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
// Create a ViewModel the first time the system calls an activity's onCreate() method.
// Re-created activities receive the same MyViewModel instance created by the first activity.
// Use the 'by viewModels()' Kotlin property delegate
// from the activity-ktx artifact
val model: MyViewModel by viewModels()
model.getUsers().observe(this, Observer<List<User>>{ users ->
// update UI
})
}
}
在 Activity 建立 MyViewModel
,接著使用剛剛提供給外部用的 getUsers()
來觀察資料的狀態,這樣就可以在資料更新時來刷新畫面。
現在對 MVVM 認識的差不多後,來實作看看~~
在 gradle(Modual) 內加入:
// Coroutine
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4"
def lifecycle_version = "2.6.2"
// ViewModel
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version"
// Fix Duplicate class
implementation(platform("org.jetbrains.kotlin:kotlin-bom:1.8.0"))
// LiveData
implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version"
// Annotation processor (Java8)
implementation "androidx.lifecycle:lifecycle-common-java8:$lifecycle_version"
下面這行是由於我的 IDE 有報錯,所以才加入,不確定是否大家都會遇到 :
// Fix Duplicate class
implementation(platform("org.jetbrains.kotlin:kotlin-bom:1.8.0"))
詳細可參考這邊。
在開始之前,我們先到 MainActivity
,把 RecyclerView 的初始化寫的更 Kotlin 一些 :
private fun initView() {
adapter = MainAdapter(mutableListOf())
binding.recyclerView.apply {
layoutManager = LinearLayoutManager(context, LinearLayoutManager.VERTICAL, false)
adapter = this@MainActivity.adapter
}
// binding.recyclerView.layoutManager = LinearLayoutManager(this, LinearLayoutManager.VERTICAL, false)
// binding.recyclerView.adapter = adapter
}
改用 apply {}
,將 recyclerView 的初始化統一在這個區塊處理 :
binding.recyclerView.apply {}
注意 在 apply
區塊內的 this
對象是 RecyclerView,而不是 MainActivity :
想看更詳細的教學可以參考這邊。
避免混淆,先幫 Model 改名子為 MainRepository
,只是改名,做的事情都一樣是向網路拿資料 :
class MainRepository: MainContract.Model {}
接著去 MainContract
調整方法參數 :
這邊會做兩件事 :
interface MainContract {
interface Model {
suspend fun getCoffeeShopsAsync(city: String?) : Deferred<List<Cafe>>
}
}
接著回到 MainRepository
,這裡將先前的 Json 字串解析提出,成為 parseCoffeeShops(responseBody: String?)
,之後我們會改用 Gson 或是 Moshi library 處理 :
private fun parseCoffeeShops(responseBody: String?): List<Cafe> {
try {
// 建立 JsonArray
val jsonArray = JSONArray(responseBody)
// 建立回傳的資料
val cafeList = mutableListOf<Cafe>()
// 解析資料
for (i in 0 until jsonArray.length()) {
val jsonObject = jsonArray.getJSONObject(i)
val id = jsonObject.getString("id")
val name = jsonObject.getString("name")
val city = jsonObject.getString("city")
val wifi = jsonObject.getInt("wifi")
val seat = jsonObject.getInt("seat")
val quiet = jsonObject.getInt("quiet")
val tasty = jsonObject.getInt("tasty")
val cheap = jsonObject.getInt("cheap")
val music = jsonObject.getInt("music")
val url = jsonObject.getString("url")
val address = jsonObject.getString("address")
val latitude = jsonObject.getString("latitude")
val longitude = jsonObject.getString("longitude")
val limitedTime = jsonObject.getString("limited_time")
val socket = jsonObject.getString("socket")
val standingDesk = jsonObject.getString("standing_desk")
val mrt = jsonObject.getString("mrt")
val openTime = jsonObject.getString("open_time")
val cafe = Cafe(
id, name, city, wifi, seat, quiet, tasty, cheap, music, url, address,
latitude, longitude, limitedTime, socket, standingDesk, mrt, openTime
)
cafeList.add(cafe)
}
// 回傳結果
return cafeList
}
catch (e: JSONException) {
e.printStackTrace()
throw CoffeeShopsRefreshError("Unable to refresh data: ${e.message}", e)
}
}
接著來修改 getCoffeeShopsAsync(city: String?)
:
override suspend fun getCoffeeShopsAsync(city: String?): Deferred<List<Cafe>> {
return coroutineScope {
async(Dispatchers.IO) {
// 創建一個 OkHttpClient 實例
val client = OkHttpClient()
// 設置要發送的 HTTP 請求
val request = Request.Builder()
.url("http://cafenomad.tw/api/v1.2/cafes/$city")
.build()
// 發起請求
val response = try {
// 使用 OkHttpClient 發送同步請求
client.newCall(request).execute()
}
catch (cause: IOException) {
cause.printStackTrace()
throw CoffeeShopsRefreshError("Unable to refresh data: ${cause.message}", cause)
}
// 檢查是否成功
if (response.isSuccessful) {
// 解析回傳資料
val coffeeShops = parseCoffeeShops(response.body?.string())
coffeeShops
}
else {
// 處理請求失败的情况
throw CoffeeShopsRefreshError("Unable to refresh data", null)
}
}
}
}
請求成功會回傳 coffeeShop: List<Cafe>
;失敗則是拋出 CoffeeShopsRefreshError()
例外。
接著來建立 MainViewModel
, 直接上建立好的程式碼 :
class MainViewModel(private val repository: MainContract.Model) : ViewModel() {
// 內部的咖啡廳列表
private val _cafes: MutableLiveData<List<Cafe>> by lazy {
MutableLiveData<List<Cafe>>().also {
viewModelScope.launch {
loadCafes()
}
}
}
// 提供給外部取得咖啡廳列表
val coffeeShopsLiveData: LiveData<List<Cafe>> get() = _cafes
suspend fun loadCafes() {
try {
// 發起非同步請求取得咖啡廳資料
val deferredCoffeeShops = repository.getCoffeeShopsAsync("")
// 等待非同步執行結果
val coffeeShops = deferredCoffeeShops.await()
// 将结果設置到 LiveData 中
_cafes.postValue(coffeeShops)
}
catch (e: CoffeeShopsRefreshError) {
_cafes.postValue(mutableListOf())
}
}
}
還記得前面有提到 ViewModel
不可直接操作資料,所以這邊將 Repository
傳入,透過 Repository
來取得網路資料。
在最前面的範例中,原本在 Activity 建立 ViewModel 是透過這行程式碼 :
val model: MyViewModel by viewModels()
但因為我們需要由外部傳入 Repository 給 ViewModel 持有,所以需要透過 Factory
來建立 ViewModel 的實例,這邊指的 Factory
就是透過工廠模式來處理 :
class MainFactory(var repository: MainModel) : ViewModelProvider.Factory {
}
接著實作它的方法 create()
,待會會在裡面建立 MainViewModel
實例 :
override fun <T : ViewModel> create(modelClass: Class<T>): T {}
完成 MainFactory
:
class MainFactory(var repository: MainModel) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
if(modelClass.isAssignableFrom(MainViewModel::class.java)) {
return MainViewModel(repository) as T
}
throw IllegalArgumentException("Unknown ViewModel class")
}
}
接著來到 MainActivity
,先來建立 MainViewModel
:
private lateinit var viewModel: MainViewModel
// 初始化 ViewModel
val repository = MainRepository()
val factory = MainFactory(repository)
viewModel = ViewModelProvider(this, factory).get(MainViewModel::class.java)
接著,來監聽資料的狀態,若有變動,則更新畫面 :
// 觀察資料狀態
viewModel.coffeeShopsLiveData.observe(this, Observer<List<Cafe>> { cafes ->
binding.tvErr.text = ""
adapter.setData(cafes)
adapter.notifyDataSetChanged()
binding.progressbar.visibility = View.GONE
})
最後是按下按鈕時,從原本的 presenter 負責,改為由 viewModel 處理 :
// TODO: 取得咖啡廳資料
binding.button.setOnClickListener {
lifecycleScope.launch(Dispatchers.Main) {
binding.progressbar.visibility = View.VISIBLE
viewModel.loadCafes()
}
}
現在全部調整完畢拉~~~
現在旋轉螢幕後不需要特別處理資料也會顯示~~~
這邊建議透過viewModel直接用viewModelScop.launch {..} (寫在viewModel.loadCafes()裡面) , activity也就不會需要透過lifecycleScope.launch去呼叫,使用上也比較簡便,更好的話連 binding.progressbar.visibility 也都會透過viewModel在乎叫loadCafes後透過另一個liveData來異動他
// TODO: 取得咖啡廳資料
binding.button.setOnClickListener {
lifecycleScope.launch(Dispatchers.Main) {
binding.progressbar.visibility = View.VISIBLE
viewModel.loadCafes()
}
}