【Android】 MVVM實作 - 使用ViewModel, LiveData, DataBinding

今天要利用Youbike場站資訊的OpenData來練習MVVM的架構,主要功能就是每30秒去抓一次資料然後顯示出來。這邊會拆分成幾個步驟來做,首先會先把資料的部分處理好,也就是Call API的部分;再來會實作LiveData及DataBinding的功能,那就來看看該怎麼做吧!

資料來源

這次使用的是由台北市政府提供的「YouBike臺北市公共自行車即時資訊」
API名稱:YouBike臺北市公共自行車即時資訊
API網址https://tcgbusfs.blob.core.windows.net/blobyoubike/YouBikeTP.json

引入套件

由於會使用到KAPT(Kotlin的Annotation Processing),DataBinding,anko,recyclerView及lambda的寫法,所以需要對Gradle檔案做些修改。

KAPT

apply plugin: 'kotlin-kapt'

android { 
    kapt { 
        generateStubs = true 
    } 
} 

dependencies { 
    implementation 'androidx.activity:activity-ktx:1.1.0' 
}
build.gradle

DataBinding

android { 
    dataBinding { 
    	enabled true 
    }
}
    
dependencies { 
    kapt 'com.android.databinding:compiler:4.0.0' 
}
build.gradle

Anko

dependencies { 
    implementation 'org.jetbrains.anko:anko:0.10.8' 
}
build.gradle

RecyclerView

dependencies { 
    implementation 'androidx.recyclerview:recyclerview:1.1.0' 
}
build.gradle

Lambda

android { 
    kotlinOptions { 
        jvmTarget = '1.8' 
    } 
}
build.gradle

資料抓取

我們先來看API抓回來的資料結構,並依這個資料結構建立對應的data class「StationData」

{
   "retCode":1,
   "retVal":{
      "0001":{
         "sno":"0001",
         "sna":"捷運市政府站(3號出口)",
         "tot":"180",
         "sbi":"84",
         "sarea":"信義區",
         "mday":"20200604160144",
         "lat":"25.0408578889",
         "lng":"121.567904444",
         "ar":"忠孝東路/松仁路(東南側)",
         "sareaen":"Xinyi Dist.",
         "snaen":"MRT Taipei City Hall Stataion(Exit 3)-2",
         "aren":"The S.W. side of Road Zhongxiao East Road & Road Chung Yan.",
         "bemp":"83",
         "act":"1"
      }
   }
}
API回傳JSON資料

我們需要用到的資料是retVal裡面的jsonObject,所以我們可以把StationData設計成這樣

data class StationData(
    val sno: String? = null,
    val sna: String? = null, 
    val tot: String? = null, 
    val sbi: String? = null, 
    val sarea: String? = null, 
    val mday: String? = null, 
    val lat: String? = null, 
    val lng: String? = null, 
    val ar: String? = null, 
    val sareaen: String? = null, 
    val snaen: String? = null, 
    val aren: String? = null, 
    val bemp: String? = null, 
    val act: String? = null 
)
StationData.kt

接著就可以開始著手接資料了,我們先建立一個對應MainActivity的MainViewModel,在這個ViewModel裡面我們會宣告一個LiveData形式的stationList,然後會有一個getBikeDataFromServer的函示去抓取資料並轉換成StationData的類別存放進stationList裡,最後會有一個startRetrieveData的函示讓他每30秒觸發getBikeDataFromServer一次去更新資料。

由於會使用到網路,所以要記得先到AndroidManifest.xml裡去新增網路的權限

<uses-permission android:name="android.permission.INTERNET"/>
AndroidManifest.xml
class MainViewModel : ViewModel() { 
    var stationList = MutableLiveData<List<StationData>>() 
    var delay = 30 * 1000L 
    
    fun startRetrieveData() { 
    	Timer().schedule(timerTask{ 
    		getBikeDataFromServer() 
        }, 0, delay) } 
        
    private fun getBikeDataFromServer() { 
    	doAsync { 
        	val tempList = mutableListOf<StationData>() 
            val url = "https://tcgbusfs.blob.core.windows.net/blobyoubike/YouBikeTP.json" 
            val jsonStr = URL(url).readText() 
            val jsonObj = JSONObject(jsonStr).getJSONObject("retVal") 
            
            jsonObj.keys().forEach { 
            	val stationObj = jsonObj.getJSONObject(it) 
                tempList.add( 
                	StationData( 
                    	stationObj.getString("sno"), 
                        stationObj.getString("sna"), 
                        stationObj.getString("tot"), 
                        stationObj.getString("sbi"), 
                        stationObj.getString("sarea"), 
                        stationObj.getString("mday"), 
                        stationObj.getString("lat"), 
                        stationObj.getString("lng"), 
                        stationObj.getString("ar"), 
                        stationObj.getString("sareaen"), 
                        stationObj.getString("snaen"), 
                        stationObj.getString("aren"), 
                        stationObj.getString("bemp"), 
                        stationObj.getString("act") 
                    ) 
                ) 
            } 
        	stationList.postValue(tempList) 
    	} 
	} 
}
MainViewModel.kt

資料呈現

接著我們來處理資料呈現的部分,這次我們僅簡單利用RecyclerView以列表的方式來呈現資料,所以先到activity_main.xml裡去新增RecyclerView。

<?xml version="1.0" encoding="utf-8"?> 
<androidx.constraintlayout.widget.ConstraintLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity"> 
        
    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/recycler_view"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        tools:listitem="@layout/item_recycler" />
</androidx.constraintlayout.widget.ConstraintLayout>
activity_main.xml

再來接著處理,RecyclerView裡面的item_recycler.xml。在這裡我們會同時採用DataBinding的技術,如此一來資料就會自動對應到Model裡的屬性。要使用DataBinding時,layout的最外層必須是<layout>,而裡面需要含有一個<data>的區塊。

<layout xmlns:android="http://schemas.android.com/apk/res/android" 
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tool="http://schemas.android.com/tools"> 
    
    <data> 
        <import type="com.jeremyhuang.mvvmsample.model.StationData" />
        <variable name="item" type="StationData" /> 
    </data> 
</layout>
item_recycler.xml

再來我們就可以開始設計畫面,然後在需要綁定資料的地方以@{`場站名稱: ` + item.sna}的方式來綁定,所以最後應該會長成這樣:

<layout xmlns:android="http://schemas.android.com/apk/res/android" 
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tool="http://schemas.android.com/tools">
    
    <data>
        <import type="com.jeremyhuang.mvvmsample.model.StationData" />
        <variable name="item" type="StationData" />
    </data>
    
    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content" 
        android:padding="10dp">
        
        <androidx.constraintlayout.widget.Guideline
            android:id="@+id/guide_50" 
            android:layout_width="wrap_content" 
            android:layout_height="wrap_content" 
            android:orientation="vertical" 
            app:layout_constraintGuide_percent=".5" /> 

        <TextView 
            android:id="@+id/tv_station_name" 
            android:layout_width="wrap_content" 
            android:layout_height="wrap_content" 
            android:text="@{`場站名稱: ` + item.sna}" 
            app:layout_constraintStart_toStartOf="parent" 
            app:layout_constraintTop_toTopOf="parent" 
            tool:text="場站名稱" /> 
               
        <TextView 
            android:id="@+id/tv_addr" 
            android:layout_width="wrap_content" 
            android:layout_height="wrap_content" 
            android:text="@{`地址: ` + item.ar}" 
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@+id/tv_station_name" 
            tool:text="地" /> 
            
        <TextView 
            android:id="@+id/tv_bike_count" 
            android:layout_width="wrap_content" 
            android:layout_height="wrap_content" 
            android:text="@{`場站目前車輛數量: ` + item.sbi}" 
            app:layout_constraintStart_toStartOf="parent" 
            app:layout_constraintTop_toBottomOf="@+id/tv_addr" 
            tool:text="場站目前車輛數量" /> 
                
        <TextView 
            android:id="@+id/tv_park_count" 
            android:layout_width="wrap_content" 
            android:layout_height="wrap_content" 
            android:text="@{`空位數量: ` + item.bemp}" 
            app:layout_constraintStart_toStartOf="@id/guide_50" 
            app:layout_constraintTop_toBottomOf="@+id/tv_addr" 
            tool:text="空位數量" /> 
           
        <View 
            android:layout_width="match_parent" 
            android:layout_height="1dp" 
            android:layout_marginTop="5dp" 
            android:background="@color/colorPrimaryDark" 
            app:layout_constraintTop_toBottomOf="@id/tv_park_count" />
    
    </androidx.constraintlayout.widget.ConstraintLayout>
</layout>
item_recycler.xml

接著我們開始著手處理RecyclerView的Adapter,因為使用DataBinding所以在ViewHolder裡接收的不是View而是ViewDataBinding,而在onBindViewHolder裡則是直接把資料的類別傳給View,所以我們的Adapter便會是這樣

class StationAdapter(
    private var context: Context, private var list: List<StationData>): 
RecyclerView.Adapter<StationAdapter.ViewHolder>(){ 

    inner class ViewHolder(var dataBinding: ViewDataBinding) : 
        RecyclerView.ViewHolder(dataBinding.root) 
        
    override fun onCreateViewHolder(
        parent: ViewGroup, viewType: Int): ViewHolder { 
        return ViewHolder( 
            DataBindingUtil.inflate(
                LayoutInflater.from(context), 
                R.layout.item_recycler, parent, false
            )
        )
    }
    
    override fun getItemCount(): Int { 
        return list.size
    }
    
    override fun onBindViewHolder(holder: ViewHolder, position: Int) {
        val binding: ViewDataBinding = holder.dataBinding 
        binding.setVariable(BR.item, list[position])
    }
}
StationAdapter.kt

剩下的事情就簡單了,我們只需要把RecyclerView設定好,把LiveData的觀察設定好,然後觸發startRetrieveData讓他開始自動去抓取資料即可。我們分別利用initView及initData兩個函式來處理前兩件事。

private fun initView() { 
    val layoutManager = LinearLayoutManager(this) 
    layoutManager.orientation = LinearLayoutManager.VERTICAL 
    recycler_view.layoutManager = layoutManager 
}

private fun initData() {
    model.stationList.observe(this, Observer { 
        recycler_view.adapter = StationAdapter(this@MainActivity, it) 
    })
}
MainActivity.kt

最後我們在onCreate的最後面呼叫model.startRetrieveData()來開始抓取資料,以下是整個MainActivity的程式:

class MainActivity : AppCompatActivity() { 
    private val model: MainViewModel by viewModels() 
    
    override fun onCreate(savedInstanceState: Bundle?) { 
        super.onCreate(savedInstanceState) 
        setContentView(R.layout.activity_main) 
        
        initView() 
        initData() 
        
        model.startRetrieveData() 
    } 
    
    private fun initView() { 
        val layoutManager = LinearLayoutManager(this) 
        layoutManager.orientation = LinearLayoutManager.VERTICAL 
        recycler_view.layoutManager = layoutManager 
    } 
    
    private fun initData() { 
        model.stationList.observe(this, Observer { 
            recycler_view.adapter = 
                StationAdapter(this@MainActivity, it)
        })
    } 
    
    class StationAdapter(
        private var context: Context, 
        private var list: List<StationData>): 
        RecyclerView.Adapter<StationAdapter.ViewHolder>(){ 
        
        inner class ViewHolder(var dataBinding: ViewDataBinding) : 
            RecyclerView.ViewHolder(dataBinding.root)
            
        override fun onCreateViewHolder(
            parent: ViewGroup, viewType: Int): 
            ViewHolder { 
                
            return ViewHolder( 
                DataBindingUtil.inflate(
                    LayoutInflater.from(context), 
                    R.layout.item_recycler, parent, false) 
            ) 
        } 
            
        override fun getItemCount(): Int { 
            return list.size 
        } 
            
        override fun onBindViewHolder(
            holder: ViewHolder, position: Int) { 
            val binding: ViewDataBinding = holder.dataBinding 
            binding.setVariable(BR.item, list[position]) 
        }
    } 
}
MainActivity.kt

這時候執行App應該就可以看到資料跑出來了,並且每30秒會刷新一次。