Phát triển Android hiện đại với Kotlin (Phần 3)

Giới thiệu về các thành phần nhận biết vòng đời (Lifecycle-aware components)

Rất nhiều developer gặp phải vấn đề liên quan đến vòng đời trong Android, vì vậy Google đã tạo ra một library để hỗ trợ chúng ta giải quyết vấn đề này. Class ViewModel là một trong số chúng. Nó là class mà tất cả các ViewModel (trong mô hình MVVM) của chúng ta nên extend.

Chúng ta hãy tạo class MainViewModel extend từ ViewModel của Lifecycle-aware component. Đầu tiên, chúng ta add thư viện Lifecycle-aware component vào file build.gradle:

dependencies {
    ... 

    implementation "android.arch.lifecycle:runtime:1.0.0-alpha9"
    implementation "android.arch.lifecycle:extensions:1.0.0-alpha9"
    kapt "android.arch.lifecycle:compiler:1.0.0-alpha9"
}

Bây giờ, extend MainViewModel từ ViewModel:

package me.fleka.modernandroidapp

import android.arch.lifecycle.ViewModel

class MainViewModel : ViewModel() {
    ...
}

Và trong function onCreate() của MainActivity, chúng ta có:

class MainActivity : AppCompatActivity() {

    lateinit var binding: ActivityMainBinding
    
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        binding = DataBindingUtil.setContentView(this, R.layout.activity_main)
        binding.viewModel = ViewModelProviders.of(this).get(MainViewModel::class.java)
        binding.executePendingBindings()

    }
}

Lưu ý rằng, chúng ta không tạo một instance mới của MainViewModel. Chúng ta lấy nó từ ViewModelProviders. ViewModelProviders là lớp utility có method cho phép lấy ra ViewModelProvider. Vì vậy, nếu gọi ViewModelProviders.of(this) trong Activity thì ViewModel sẽ sống cho đến khi Activity đó còn sống (bị destroy và không recreate lại). Tương tự, nếu bạn gọi nó trong Fragment thì ViewModel của bạn sẽ sống cho đến khi Fragment đó còn sống. Xem biểu đồ sau:

ViewModelProvider có trách nhiệm tạo instance mới nếu nó được gọi lần đầu tiên và return lại instance cũ mỗi khi Activity/Fragment được recreated. Quan sát điều xảy ra khi chúng ta xoay màn hình: Trước khi sử dụng ViewModelProvider: Khi xoay màn hình, dữ liệu cũ bị hiển thị lại.

Sau khi lấy ViewModel từ ViewModelProvider: Dữ liệu là như nhau trước và sau khi xoay màn hình.

Ví dụ fetch danh sách Github Repository và hiển thị nó

Để thực hiện nó, chúng ta thêm function getRepositories mà sẽ return lại danh sách repositories giả lập:

class RepoModel {

    fun refreshData(onDataReadyCallback: OnDataReadyCallback) {
        Handler().postDelayed({ onDataReadyCallback.onDataReady("new data") },2000)
    }

    fun getRepositories(onRepositoryReadyCallback: OnRepositoryReadyCallback) {
        var arrayList = ArrayList<Repository>()
        arrayList.add(Repository("First", "Owner 1", 100 , false))
        arrayList.add(Repository("Second", "Owner 2", 30 , true))
        arrayList.add(Repository("Third", "Owner 3", 430 , false))
        
        Handler().postDelayed({ onRepositoryReadyCallback.onDataReady(arrayList) },2000)
    }
}

interface OnDataReadyCallback {
    fun onDataReady(data : String)
}

interface OnRepositoryReadyCallback {
    fun onDataReady(data : ArrayList<Repository>)
}

Ngoài ra, chúng ta nên có thêm function trong MainViewModel mà sẽ gọi đến getRepositories từ RepoModel:

class MainViewModel : ViewModel() {
    ...
    var repositories = ArrayList<Repository>()

    fun refresh(){
        ...
    }

    fun loadRepositories(){
        isLoading.set(true)
        repoModel.getRepositories(object : OnRepositoryReadyCallback{
            override fun onDataReady(data: ArrayList<Repository>) {
                isLoading.set(false)
                repositories = data
            }
        })
    }
}

Và khi kết thúc, chúng ta sẽ hiển thị các repositories trong RecyclerView. Để làm được điều đó, chúng ta phải:

  • tạo layout rv_item_repository.xml
  • add RecyclerView vào layout activity_main.xml
  • tạo RepositoryRecyclerViewAdapter
  • set adapter cho recyclerview

Để tạo rv_item_repository.xml, chúng ta sử dụng thư viện CardView:

<?xml version="1.0" encoding="utf-8"?>
<layout 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">

    <data>

        <import type="android.view.View" />

        <variable
            name="repository"
            type="me.fleka.modernandroidapp.uimodels.Repository" />
    </data>

    <android.support.v7.widget.CardView
        android:layout_width="match_parent"
        android:layout_height="96dp"
        android:layout_margin="8dp">

        <android.support.constraint.ConstraintLayout
            android:layout_width="match_parent"
            android:layout_height="match_parent">

            <TextView
                android:id="@+id/repository_name"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_marginEnd="16dp"
                android:layout_marginStart="16dp"
                android:text="@{repository.repositoryName}"
                android:textSize="20sp"
                app:layout_constraintBottom_toBottomOf="parent"
                app:layout_constraintHorizontal_bias="0.0"
                app:layout_constraintLeft_toLeftOf="parent"
                app:layout_constraintRight_toRightOf="parent"
                app:layout_constraintTop_toTopOf="parent"
                app:layout_constraintVertical_bias="0.083"
                tools:text="Modern Android App" />

            <TextView
                android:id="@+id/repository_has_issues"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_marginEnd="16dp"
                android:layout_marginStart="16dp"
                android:layout_marginTop="8dp"
                android:text="@string/has_issues"
                android:textStyle="bold"
                android:visibility="@{repository.hasIssues ? View.VISIBLE : View.GONE}"
                app:layout_constraintBottom_toBottomOf="@+id/repository_name"
                app:layout_constraintEnd_toEndOf="parent"
                app:layout_constraintHorizontal_bias="1.0"
                app:layout_constraintStart_toEndOf="@+id/repository_name"
                app:layout_constraintTop_toTopOf="@+id/repository_name"
                app:layout_constraintVertical_bias="1.0" />

            <TextView
                android:id="@+id/repository_owner"
                android:layout_width="0dp"
                android:layout_height="wrap_content"
                android:layout_marginBottom="8dp"
                android:layout_marginEnd="16dp"
                android:layout_marginStart="16dp"
                android:text="@{repository.repositoryOwner}"
                app:layout_constraintBottom_toBottomOf="parent"
                app:layout_constraintEnd_toEndOf="parent"
                app:layout_constraintStart_toStartOf="parent"
                app:layout_constraintTop_toBottomOf="@+id/repository_name"
                app:layout_constraintVertical_bias="0.0"
                tools:text="Mladen Rakonjac" />

            <TextView
                android:id="@+id/number_of_starts"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_marginBottom="8dp"
                android:layout_marginEnd="16dp"
                android:layout_marginStart="16dp"
                android:layout_marginTop="8dp"
                android:text="@{String.valueOf(repository.numberOfStars)}"
                app:layout_constraintBottom_toBottomOf="parent"
                app:layout_constraintEnd_toEndOf="parent"
                app:layout_constraintHorizontal_bias="1"
                app:layout_constraintStart_toStartOf="parent"
                app:layout_constraintTop_toBottomOf="@+id/repository_owner"
                app:layout_constraintVertical_bias="0.0"
                tools:text="0 stars" />

        </android.support.constraint.ConstraintLayout>

    </android.support.v7.widget.CardView>

</layout>

Bước tiếp theo, thêm RecyclerView vào activity_main.xml:

<?xml version="1.0" encoding="utf-8"?>
<layout 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">

    <data>

        <import type="android.view.View"/>

        <variable
            name="viewModel"
            type="me.fleka.modernandroidapp.MainViewModel" />
    </data>

    <android.support.constraint.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        tools:context="me.fleka.modernandroidapp.MainActivity">

        <ProgressBar
            android:id="@+id/loading"
            android:layout_width="48dp"
            android:layout_height="48dp"
            android:indeterminate="true"
            android:visibility="@{viewModel.isLoading ? View.VISIBLE : View.GONE}"
            app:layout_constraintBottom_toTopOf="@+id/refresh_button"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent" />

        <android.support.v7.widget.RecyclerView
            android:id="@+id/repository_rv"
            android:layout_width="0dp"
            android:layout_height="0dp"
            android:indeterminate="true"
            android:visibility="@{viewModel.isLoading ? View.GONE : View.VISIBLE}"
            app:layout_constraintBottom_toTopOf="@+id/refresh_button"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent"
            tools:listitem="@layout/rv_item_repository" />



        <Button
            android:id="@+id/refresh_button"
            android:layout_width="160dp"
            android:layout_height="40dp"
            android:layout_marginBottom="8dp"
            android:layout_marginEnd="8dp"
            android:layout_marginStart="8dp"
            android:layout_marginTop="8dp"
            android:onClick="@{() -> viewModel.loadRepositories()}"
            android:clickable="@{viewModel.isLoading ? false : true}"
            android:text="Refresh"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent"
            app:layout_constraintVertical_bias="1.0" />

    </android.support.constraint.ConstraintLayout>

</layout>

Bây giờ, chúng ta add Adapter cho RecyclerView:

class RepositoryRecyclerViewAdapter(private var items: ArrayList<Repository>,
                                    private var listener: OnItemClickListener)
    : RecyclerView.Adapter<RepositoryRecyclerViewAdapter.ViewHolder>() {

    override fun onCreateViewHolder(parent: ViewGroup?, viewType: Int): ViewHolder {
        val layoutInflater = LayoutInflater.from(parent?.context)
        val binding = RvItemRepositoryBinding.inflate(layoutInflater, parent, false)
        return ViewHolder(binding)
    }

    override fun onBindViewHolder(holder: ViewHolder, position: Int)
            = holder.bind(items[position], listener)

    override fun getItemCount(): Int = items.size

    interface OnItemClickListener {
        fun onItemClick(position: Int)
    }

    class ViewHolder(private var binding: RvItemRepositoryBinding) :
            RecyclerView.ViewHolder(binding.root) {

        fun bind(repo: Repository, listener: OnItemClickListener?) {
            binding.repository = repo
            if (listener != null) {
                binding.root.setOnClickListener({ _ -> listener.onItemClick(layoutPosition) })
            }

            binding.executePendingBindings()
        }
    }

}

Cuối cùng, chúng ta set adapter cho recyclerView trong MainActivity:

class MainActivity : AppCompatActivity(), RepositoryRecyclerViewAdapter.OnItemClickListener {

    lateinit var binding: ActivityMainBinding

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        binding = DataBindingUtil.setContentView(this, R.layout.activity_main)
        val viewModel = ViewModelProviders.of(this).get(MainViewModel::class.java)
        binding.viewModel = viewModel
        binding.executePendingBindings()

        binding.repositoryRv.layoutManager = LinearLayoutManager(this)
        binding.repositoryRv.adapter = RepositoryRecyclerViewAdapter(viewModel.repositories, this)

    }

    override fun onItemClick(position: Int) {
        TODO("not implemented") //To change body of created functions use File | Settings | File Templates.
    }
}

Ví dụ trên sẽ hiển thị danh sách Repositories nhờ vào RecyclerView.