今天要利用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' 
}

DataBinding

android {
    dataBinding {
        enabled true
    }
}

dependencies {
    kapt 'com.android.databinding:compiler:4.0.0'
}

Anko

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

RecyclerView

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

Lambda

android {
    kotlinOptions {
        jvmTarget = '1.8'
    }
}

資料抓取

我們先來看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"
        }
    }
}

我們需要用到的資料是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
)

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

由於會使用到網路,所以要記得先到AndroidManifest.xml裡去新增網路的權限<uses-permission android:name="android.permission.INTERNET"/>

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)
        }
    }
}

資料呈現

接著我們來處理資料呈現的部分,這次我們僅簡單利用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>

再來接著處理,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.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>

接著我們開始著手處理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])
        }

    }

剩下的事情就簡單了,我們只需要把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)
    })
}

最後我們在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])
        }

    }
}

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