iT邦幫忙

2021 iThome 鐵人賽

DAY 11
1
Mobile Development

挑戰 Kotlin Multiplatform Mobile 跨平台開發,透過共同的Kotlin模組同時打造iOS與Android應用!系列 第 11

Day 11: 回到原生環境!在Android上展示Ktor資料!

Keyword: Android ViewModel,Coroutine,LiveData,RecyclerView
到Day11使用Ktor進行網路請求並且顯示在Android畫面的Code放在
KMMDay11


有了shared內的資料,我們就要真正的來使用這些資料了

建立DataRepository

先在shared/commonMain/model/的路徑下建立一個DataRepository的class,當成shared與雙平台交互的地方.

這層Respository雖然不是必要的,Android與iOS可以直接呼叫剛剛建立好的CafeApiImpl來使用其中的方法進行網路請求.但還是推薦額外建立這層Repositroy.

這層Repositroy讓Android或iOS平台和資料的來源隔離,雙平台並不需要知道資料從哪裡來的,從Api拉取或是從本地DB庫撈出來對於使用的平台根本一點都不重要.我只要這個資料能夠使用就好.

class DataRepository {

    private val ktorApi: CafeApi by lazy { CafeApiImpl() }
    suspend fun fetchCafesFromNetwork(cityName: String): List<CafeResponseItem> {
        try {
            return ktorApi.fetchCafeFromApi(cityName)
        } catch (e: Exception) {
            println(e.message)
        }
        return listOf()
    }

}

撰寫測試的時候,也能根據情境切換不同的資料源,而對於使用資料的那方毫無影響.再配合上依賴注入,就能更加解耦,在多平台的專案中,解耦程度越高,修改越容易,伴隨產生side effect的機率就更低.

使用Coroutine與ViewModel

在DataRepository內的fetchCafesFromNetwork是一個suspend function,意味著這個function需要運行在一個coroutine的環境內.

昨天我們在gradle(shared)已經加入了coroutine的依賴,因此目前在shared模組內是可以使用coroutine的,但是在androidApp的模組內還沒有,所以會發生錯誤.

另外我們在Android平台,今天還會使用到ViewModel與Ktor等等的組件,也一起加入到androidApp的gradle中

記得加入DSL來管理新的依賴.

//在gradle(android)中 添加以下依賴
dependencies {
		...
    implementation(Develop.Ktor.androidCore)
    implementation(Develop.Coroutines.common)
    implementation(Develop.Coroutines.android)
    implementation(Develop.AndroidX.lifecycle_runtime)
    implementation(Develop.AndroidX.lifecycle_viewmodel)
    implementation(Develop.AndroidX.lifecycle_viewmodel_extensions)
		...
}
//在Dependencies.kts中 添加依賴版本管理
object Versions{
    val ktor = "1.6.3"
    val coroutines =  "1.5.0-native-mt"
    val serialization_version = "1.5.21"

    object AndroidX {
        val core = "1.6.0"
        val lifecycle = "2.4.0-alpha02"
        val test = "1.3.0"
        val test_ext = "1.1.2"
    }
}

object Develop{
    object Ktor{
        ...
        val androidCore = "io.ktor:ktor-client-okhttp:${Versions.ktor}"
        ...
    }

    object Coroutines{
        val common = "org.jetbrains.kotlinx:kotlinx-coroutines-core:${Versions.coroutines}"
        val android = "org.jetbrains.kotlinx:kotlinx-coroutines-android:${Versions.coroutines}"
        val test = "org.jetbrains.kotlinx:kotlinx-coroutines-test:${Versions.coroutines}"
    }

    object AndroidX {
        val core_ktx = "androidx.core:core-ktx:${Versions.AndroidX.core}"
        val lifecycle_runtime = "androidx.lifecycle:lifecycle-runtime-ktx:${Versions.AndroidX.lifecycle}"
        val lifecycle_viewmodel = "androidx.lifecycle:lifecycle-viewmodel:${Versions.AndroidX.lifecycle}"
        val lifecycle_viewmodel_extensions = "androidx.lifecycle:lifecycle-viewmodel-ktx:${Versions.AndroidX.lifecycle}"
    }
}

之後記得有新的依賴也要到這邊來修改.

然後因為我們將要使用網路請求,所以需要在AndroidManifest中註冊權限,告訴Android系統我們將會進行網路請求

<uses-permission android:name="android.permission.INTERNET"/>

在ViewModel中進行網路請求

在androidApp模組中,建立一個MainViewModel,繼承Google的ViewModel組件.

在其中加入之前建立好的DataRepository當作資料元,再加入MutableLiveData<List>,最後再補上一個根據MutableLiveData變化的LiveData<List>

class MainViewModel : ViewModel() {
    private val dataRepository: DataRepository = DataRepository()
    private val cafeList = MutableLiveData<List<CafeResponseItem>>()
    val cafeListLiveData: LiveData<List<CafeResponseItem>> = Transformations.map(cafeList) { it }
}

注意兩個LiveData中,普通的LiveData是public的,而Mutable的是private的.這是為了讓View層不能去修改其中的資料,而專職於顯示的部分.由架構上限制了修改的可能性.記得前面提過的單一職責原則嘛?

最後寫下一個進行網路請求的方法,由於拉取網路資料是suspend方法所以需要放在coroutine中執行,然後將回傳的資料再設置回LiveData

完整的ViewModel如下

class MainViewModel : ViewModel() {
    private val dataRepository: DataRepository = DataRepository()
    private val cafeList = MutableLiveData<List<CafeResponseItem>>()
    val cafeListLiveData: LiveData<List<CafeResponseItem>> = Transformations.map(cafeList) { it }

    fun fetchCafeData(city: String = "") {
        viewModelScope.launch() {
            val result = async { dataRepository.fetchCafesFromNetwork(city) }
            cafeList.value = result.await()
        }
    }
}

使用RecyclerView顯示資料

來到androidApp 模組內的 MainActivity,先把範例的程式碼刪掉,然後加入ViewModel.

class MainActivity : AppCompatActivity() {
    private lateinit var  viewModel : MainViewModel

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        
        viewModel = ViewModelProvider(this).get(MainViewModel::class.java)
    }
}

之後使用ViewModel進行網路請求,並且監聽LiveData

class MainActivity : AppCompatActivity() {
    private lateinit var  viewModel : MainViewModel

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        
        viewModel = ViewModelProvider(this).get(MainViewModel::class.java)
        viewModel.fetchCafeData("taipei")
        viewModel.cafeListLiveData.observe(this, Observer { it ->
						//將資料設定到RecyclerView內
        })
    }
}

接下來就是Android工程師熟悉的使用RecyclerView顯示畫面

修改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"
    android:id="@+id/main_view"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/rv_cafeList"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toTopOf="parent"
         />

</androidx.constraintlayout.widget.ConstraintLayout>

加入item_cafe.xml給RecyclerView顯示

<?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="wrap_content">

    <TextView
        android:id="@+id/tv_cafename"
        android:layout_width="150dp"
        android:layout_height="wrap_content"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        android:textSize="14sp"
        tools:text="店名" />

    <TextView
        android:id="@+id/tv_address"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        app:layout_constraintStart_toEndOf="@id/tv_cafename"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        android:textSize="14sp"
        tools:text="地址"
        />
</androidx.constraintlayout.widget.ConstraintLayout>

顯示邏輯用的ViewHolder

class CafeViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
    private val name :TextView = itemView.findViewById(R.id.tv_cafename)
    private val address :TextView = itemView.findViewById(R.id.tv_address)

    fun bind(cafe: CafeResponseItem) {
        this.name.text = cafe.name
        this.address.text = cafe.address
    }
}

用來轉換資料與顯示的adapter

class CafeAdapter : RecyclerView.Adapter<CafeViewHolder>() {
    var cafeList = listOf<CafeResponseItem>()
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CafeViewHolder {
        val view = LayoutInflater.from(parent.context).inflate(R.layout.item_cafe, parent, false)

        return CafeViewHolder(view)
    }

    override fun onBindViewHolder(holder: CafeViewHolder, position: Int) {
        val cafe:CafeResponseItem = cafeList[position]
        holder.bind(cafe)
    }

    override fun getItemCount() = cafeList.size
}

然後讓MainActivity 使用,並且將監聽LiveData的值傳入adapter中顯示,修改後的MainActivity如下

class MainActivity : AppCompatActivity() {
    private lateinit var cafeRecyclerView : RecyclerView
    private lateinit var  viewModel : MainViewModel
    private val adapter :CafeAdapter by lazy { CafeAdapter() }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        cafeRecyclerView = findViewById(R.id.rv_cafeList)
        cafeRecyclerView.adapter = adapter
        cafeRecyclerView.layoutManager = LinearLayoutManager(this, LinearLayoutManager.VERTICAL, false)
        cafeRecyclerView.addItemDecoration(DividerItemDecoration(this,DividerItemDecoration.VERTICAL))

        viewModel = ViewModelProvider(this).get(MainViewModel::class.java)
        viewModel.fetchCafeData("taipei")
        viewModel.cafeListLiveData.observe(this, Observer {
            adapter.cafeList = it
            adapter.notifyDataSetChanged()
        })
    }
}

執行的結果如下

https://github.com/officeyuli/itHome2021/raw/main/day11/recyclerView.jpg

明天將會來將Ktor的網路資料顯示在iOS畫面上


上一篇
Day 10:讓你見識我的一小部分力量,Ktor的網路請求
下一篇
Day 12: 前往未知秘境!在iOS上展示Ktor資料!
系列文
挑戰 Kotlin Multiplatform Mobile 跨平台開發,透過共同的Kotlin模組同時打造iOS與Android應用!30

尚未有邦友留言

立即登入留言