iT邦幫忙

2023 iThome 鐵人賽

DAY 17
0

今天來將架構由目前的 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 去更新。

ViewModel 、LiveData 的基本概念

ViewModel

在原先的 MVP 中,資料的持有者是 Activity,也就是說,當 Activity 的生命週期改變時會影響資料是否消失於畫面上。最常見的例子就是當螢幕旋轉時,我們的 Activity 會被銷毀,然後再重新建立。這時就會發現原本在畫面上的資料不見了,因為隨著 Activity 被銷毀,它所持有的資料也會跟著銷毀,而畫面是與資料綁定的,所以畫面上才會沒有顯示資料。

ViewModel 的優勢在於開發者不再需要為了這類型的問題或是暫時存放於記憶體的資料被清除的情況而特地處理,只要透過 ViewModel 就行了,因為它的生命週期比 Activity 還長,所以可以在螢幕旋轉時將資料繼續保存。

LiveData

上面有提到我們會搭配 LiveData 使用,那它有什麼特別的呢? LiveData 也是屬於 [androidx.lifecycle](https://developer.android.com/reference/androidx/lifecycle/package-summary?hl=zh-cn) 所提供的生命週期 library 的一員,是一種可感知生命週期的物件,實作了觀察者模式。透過感知 Activity 或是 Fragment 的生命週期,確保只會將更新後的資料通知給處於活躍狀態 (STARTEDRESUMED) 的觀察者。

它有兩種類型 :

  • MutableLiveData : 可變動的資料型別
  • LiveData : 不可變動的資料型別

來看看兩者搭配起來的基本用法,想看細節點這邊 :

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 認識的差不多後,來實作看看~~

引用 library

在 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 :

d17_1.png

想看更詳細的教學可以參考這邊

調整 MainModel 為 MainRepository

避免混淆,先幫 Model 改名子為 MainRepository,只是改名,做的事情都一樣是向網路拿資料 :

class MainRepository: MainContract.Model {}

接著去 MainContract 調整方法參數 :

這邊會做兩件事 :

  • 刪除 MainContract.View、MainContract.Presenter
  • 調整 MainContract.Model : 我們把 callback 參數拿掉,且變成有回傳值,回傳的型別為 Deferred<List>
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 來管理和持有資料

接著來建立 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 來取得網路資料。

建立 MainFactory 生成 ViewModel 實體物件

在最前面的範例中,原本在 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")
    }
}

在 Activity 透過 ViewModel 更新畫面

接著來到 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()
    }
}

現在全部調整完畢拉~~~

畫面結果

d17_2.png

現在旋轉螢幕後不需要特別處理資料也會顯示~~~

d17_3.png

今日推推

挖這篇文章居然寫了六個小時,真的是嚇壞了…
Yes


上一篇
Day16 使用 RecyclerView 顯示 API 資料
下一篇
Day18 串接 Google Maps API - 設定 Google Cloud 專案與啟用 API 金鑰 (上)
系列文
喝咖啡要30天?一起用 Kotlin 打造尋找好喝咖啡的 App30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

1 則留言

0
JLin
iT邦新手 5 級 ‧ 2023-10-02 15:07:54

這邊建議透過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()
}

}

我要留言

立即登入留言