Step by Step for MVVM + Retrofit + LiveData + coroutine + Room + Navigation Component

Lấy theo 1 playsList trên Youtobe : https://youtube.com/playlist?list=PLQkwcJG4YTCRF8XiCRESq1IFFW8COlxYJ 


Bước 1: Setting up Navigation 

- Tạo một file layout NavGraph thuộc Navigation

- Tạo một bottom menu thuộc menu để add vào bottom navigation view

<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">

<item
android:id="@+id/breakingNewsFragment"
android:title="Breaking News"
android:icon="@drawable/ic_breaking_news"/>

<item
android:id="@+id/savedNewsFragment"
android:title="Saved News"
android:icon="@drawable/ic_favorite"/>

<item
android:id="@+id/searchNewsFragment"
android:title="Search News"
android:icon="@drawable/ic_all_news"/>

</menu>

- Tại file layout activity_main tạo 2 thẻ là  bottomNavigationView và FragmentContainerView

<?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="match_parent"
tools:context=".ui.NewsActivity">

<FrameLayout
android:id="@+id/flFragment"
android:layout_width="match_parent"
android:layout_height="0dp"
app:layout_constraintBottom_toTopOf="@id/bottomNavigationView"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">

<androidx.fragment.app.FragmentContainerView
android:id="@+id/newsNavHostFragment"
android:name="androidx.navigation.fragment.NavHostFragment"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:defaultNavHost="true"
app:navGraph="@navigation/news_nav_graph" />

</FrameLayout>

<com.google.android.material.bottomnavigation.BottomNavigationView
android:id="@+id/bottomNavigationView"
android:layout_width="match_parent"
android:layout_height="56dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/flFragment"
app:menu="@menu/bottom_navigation_menu" />

</androidx.constraintlayout.widget.ConstraintLayout>

-Tại file kotlin MainActivity bắt đầu liên kết 2 thành phần bottomNavigationView với FragmentContainerView với nhau

class NewsActivity : AppCompatActivity() {

private lateinit var binding: ActivityNewsBinding
lateinit var viewModel: NewsViewModel

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityNewsBinding.inflate(layoutInflater)
setContentView(binding.root)


val newsNavHostFragment = supportFragmentManager.findFragmentById(R.id.newsNavHostFragment)!!
binding.bottomNavigationView.setupWithNavController(newsNavHostFragment.findNavController())

}

}

Do FragmentContainerView là 1 thẻ thuộc FramLayout nên để truy cập đến cần sử dụng supportFragmentManager 


Bước 2: Setup Retrofit

- Lên web chứa api để lấy BASE_URL của api và các param cho phương thức GET

+ Link lấy API : News API – Search News and Blog Articles on the Web

- Tạo 1 object để chưa các const var như KEY_API, BASE_URL


Dòng lệnh:

object Constant {
const val API_KEY = "be57db5b6afe497ebe7d4afbe515c22b"
const val BASE_URL = "https://newsapi.org/"
}


- Tạo 1 interface chưa các phương thức như GET POST.. của API một model

Những dòng lệnh:

interface NewsAPI {

@GET("v2/top-headlines")
suspend fun getBreakingNews(
@Query("country") country: String = "us",
@Query("page") pageNumber: Int = 1,
@Query("apiKey") apiKey: String = API_KEY
) : Response<NewsResponse>

@GET("v2/everything")
suspend fun searchForNews(
@Query("q") searchQuery: String,
@Query("page") pageNumber: Int = 1,
@Query("apiKey") apiKey: String = API_KEY
) : Response<NewsResponse>
}

- Tạo 1 file object để thực hiện setup và tạo Instance Retrofit

object RetrofitInstance {

private val retrofit by lazy {
Retrofit
.Builder()
.baseUrl(BASE_URL)
.addConverterFactory(GsonConverterFactory.create())
.build()
}

val api: NewsAPI by lazy {
retrofit.create(NewsAPI::class.java)
}

}

Lúc này interface sẽ được liên kết với Retrofit để thực hiện call API


Bước 3 Setup Dao an Room for Model Article khi offline


- Thêm thuộc tính cho Model để thành 1 entity

@Entity(
tableName = "articles"
)
data class Article(
@PrimaryKey(autoGenerate = true)
var id: Int? = null,
val author: String,
val content: String,
val description: String,
val publishedAt: String,
val source: Source,
val title: String,
val url: String,
val urlToImage: String
)

- Tạo interface Dao chứ nhưng phương thức cơ bản của 1 entity 

@Dao
interface ArticleDao {

@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(article: Article): Long

@Delete
suspend fun deleteArticles(article: Article)

@Query("SELECT * FROM articles")
fun getAllArticles(): LiveData<List<Article>>
}

@Insert(onConflict = OnConflictStrategy.REPLACE) : Nhằm mục đích khi có xung đột về vị trị của nhưng record trong entity như trong lặp vị trí thì lúc này record cũ sẽ được thay bằng record mới

- Tạo abstract class là file Database tại đây sẽ tạo database cho project

@Database(
entities = [Article::class],
version = 1
)
@TypeConverters(Converters::class)
abstract class ArticleDatabase : RoomDatabase() {

abstract fun getArticleDao(): ArticleDao

companion object{
@Volatile
private var instance: ArticleDatabase? = null
private val LOCK = Any()

operator fun invoke(context: Context) = instance ?: synchronized(LOCK){
instance ?: createDatabase(context).also { instance = it }
}

private fun createDatabase(context: Context) =
Room.databaseBuilder(
context.applicationContext,
ArticleDatabase::class.java,
"article_db.db"
).build()
}
}

- Tạo 1 file converter : Do Room chỉ thực thi lưu trữ những kiểu dữ liệu nguyên thuỷ vào trong database nên cần phải có 1 code để giúp Room có thể lưu trữ nhưng kiểu dữ liệu do người dùng tạo ra

class Converters {

@TypeConverter
fun fromSource(source: Source) : String {
return source.name
}

@TypeConverter
fun toSource(name: String) : Source {
return Source(name, name)
}
}

p/s : Phải khải báo annotation vào trong file database ; Như bên trên đã có


Bước 4: Tạo RecyclerView với DiffUtil

- Trong file layout của những fragment thêm thẻ <RecyclerView>

<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvBreakingNews"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:paddingBottom="50dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />

- Tạo file adapter cho recyclerView có sử dụng DiffUtil : Giúp cho việc truyền dữ liệu và update dữ liệu truyền cho RecyclerView được đơn giản và hiệu quả hơn

class NewsAdapter : RecyclerView.Adapter<NewsAdapter.ArticleViewHolder>() {

inner class ArticleViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView)

override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ArticleViewHolder {
return ArticleViewHolder(
LayoutInflater.from(parent.context).inflate(
R.layout.item_article_preview,
parent,
false
)
)
}

override fun getItemCount(): Int {
return differ.currentList.size
}

override fun onBindViewHolder(holder: ArticleViewHolder, position: Int) {
val article = differ.currentList[position]
holder.itemView.apply {
Glide.with(this).load(article.urlToImage).into(findViewById(R.id.ivArticleImage))
findViewById<TextView>(R.id.tvSource).text = article.source.name
findViewById<TextView>(R.id.tvTitle).text = article.title
findViewById<TextView>(R.id.tvDescription).text = article.description
findViewById<TextView>(R.id.tvPublishedAt).text = article.publishedAt
setOnClickListener{
onItemClickListener?.let { it(article) }
}
}
}

var onItemClickListener: ((Article) -> Unit)? = null

fun setOnItemClickListener(listener: (Article) -> Unit) {
onItemClickListener = listener
}

private val differCallback = object : DiffUtil.ItemCallback<Article>(){ //
override fun areItemsTheSame(oldItem: Article, newItem: Article): Boolean {
return oldItem.url == newItem.url
}

override fun areContentsTheSame(oldItem: Article, newItem: Article): Boolean {
return oldItem == newItem
}
}

val differ = AsyncListDiffer(this, differCallback)
}


Bước 5: Tạo Repository và ViewModel kết nối chúng với View

- Tạo Repository được liên kết với Database và Retrofit là một Param tạo ra ViewModelProviderFactory

class NewsRepository(
val db: ArticleDatabase
) {
suspend fun getBreakingNews(countryCode: String, pageNumber: Int)
= RetrofitInstance.api.getBreakingNews(countryCode, pageNumber)
}

- Tạo ViewModel() liên kết với Repository đứa dữ liệu lên View

class NewsViewModel(
private val newsRepository: NewsRepository
) : ViewModel() {

}

- Tạo ViewModelProviderFactory là Param của ViewModel() . Tạo 1 file riêng giúp cho code file ViewModel() ít hơn dẽ nhìn và và dễ bảo trì hơn

class NewsViewModelProviderFactory(
private val newsRepository: NewsRepository
) : ViewModelProvider.Factory {

override fun <T : ViewModel> create(modelClass: Class<T>): T {
return NewsViewModel(newsRepository) as T
}

}

- Tạo Instance của ViewModel() tại view (Activity)

class NewsActivity : AppCompatActivity() {

private lateinit var binding: ActivityNewsBinding
lateinit var viewModel: NewsViewModel

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityNewsBinding.inflate(layoutInflater)
setContentView(binding.root)

val newsRepository = NewsRepository(ArticleDatabase(this))
val viewModelProviderFactory = NewsViewModelProviderFactory(newsRepository)
viewModel = ViewModelProvider(this, viewModelProviderFactory)[NewsViewModel::class.java]


}

}

- Ta sẽ truyền Instance của ViewModel này đến các fragment thuộc Activiti như sau

class BreakingNewsFragment : Fragment(R.layout.fragment_breaking_news) {

private lateinit var viewModel: NewsViewModel
private lateinit var newsAdapter: NewsAdapter
private lateinit var binding: FragmentBreakingNewsBinding

override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
binding = FragmentBreakingNewsBinding.inflate(inflater, container, false)
return binding.root
}

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {

super.onViewCreated(view, savedInstanceState)
viewModel = (activity as NewsActivity).viewModel

}

- Tạo file Resource phương thức check trạng thái call API

sealed class Resource<T>(
val data: T? = null,
val message: String? = null
) {
class Success<T>(data: T) : Resource<T>(data)
class Error<T>(message: String, data: T? = null) : Resource<T>(data, message)
class Loading<T> : Resource<T>()
}

Bước 6 : Lấy dữ liệu từ dưới Retrofit thông qua ViewModel đưa lên View (BreakingNewsFragment)

-Tại file Repository

class NewsRepository(
val db: ArticleDatabase
) {
suspend fun getBreakingNews(countryCode: String, pageNumber: Int)
= RetrofitInstance.api.getBreakingNews(countryCode, pageNumber)
}

hàm getBreakingNews chạy bất đồng bộ với 2 param được gán với lệnh call API lúc này hàm getBreakingNews sẽ chứa những dòng code tương tự như hàm RetrofitInstance.api.getBreakingNews()

-Tại file ViewModel

class NewsViewModel(
private val newsRepository: NewsRepository
) : ViewModel() {

val breakingNews: MutableLiveData<Resource<NewsResponse>> = MutableLiveData()
val breakingNewsPage = 1

init {
getBreakingNews("us")
}

fun getBreakingNews(countryCode: String) = viewModelScope.launch {
breakingNews.postValue(Resource.Loading())
val response = newsRepository.getBreakingNews(countryCode, breakingNewsPage)
breakingNews.postValue(handBreakingNewsResponse(response))
}

private fun handBreakingNewsResponse(response: Response<NewsResponse>) : Resource<NewsResponse> {
if(response.isSuccessful){
response.body()?.let { resultResponse ->
return Resource.Success(resultResponse)
}
}
return Resource.Error(response.message())
}
}

+ Tạo 1 biến breakingNews với kiểu dữ liệu list LiveData của Resource<NewsResponse>

+ Tạo hàm getBreakinhNews() hàm được chạy bất đồng bộ: Trong hàm sẽ sử lý dữ liệu lấy từ API 

+Như theo code mới đầu ta gán biến breakingNews.postValue(Resource.Loading()) lúc này biến breakingNews có kiểu dữ liệu là Resource<NewsResponse>.Loading với giá trị là một list rỗng 

và câu lệnh postValue là câu lệnh của MutableLiveData chức năng thay thế giá trị của MutableLiveData bằng giá trị mới nằm trọng "( )" và thông báo cho main thread là có dữ liệu thay đổi

+ Bên dưới tạo 1 biến response thực hiện lệnh call API

+ Tiếp theo sẽ kiểm tra xem

- Tại file BreakingNewsFragment

class BreakingNewsFragment : Fragment(R.layout.fragment_breaking_news) {

private lateinit var viewModel: NewsViewModel
private lateinit var newsAdapter: NewsAdapter
private lateinit var binding: FragmentBreakingNewsBinding

override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
binding = FragmentBreakingNewsBinding.inflate(inflater, container, false)
return binding.root
}

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {

super.onViewCreated(view, savedInstanceState)
viewModel = (activity as NewsActivity).viewModel
setupRecyclerView()
viewModel.breakingNews.observe(viewLifecycleOwner, Observer { response ->
when(response){
is Resource.Success -> {
hideProgressBar()
response.data?.let { newsResponse ->
newsAdapter.differ.submitList(newsResponse.articles)
}
}
is Resource.Error -> {
hideProgressBar()
response.message?.let { message ->
Log.e("TAG", message)
}
}
is Resource.Loading -> {
showProgressBar()
}
}
})

}

private fun hideProgressBar() {
binding.paginationProgressBar.visibility = View.INVISIBLE
}

private fun showProgressBar() {
binding.paginationProgressBar.visibility = View.VISIBLE
}

private fun setupRecyclerView(){
newsAdapter = NewsAdapter()
binding.rvBreakingNews.apply {
adapter = newsAdapter
layoutManager = LinearLayoutManager(activity)
}
}
}

+ viewModel.breakingNews.observe() lấy dữ liệu bên trong LiveData

+ trong when() check xem dữ liệu đang ở trạng thái nào để truyền cho RecyclerView

- Tại file SearchNewsFragment

class SearchNewsFragment : Fragment(R.layout.fragment_search_news) {

private lateinit var viewModel: NewsViewModel
private lateinit var newsAdapter: NewsAdapter
private lateinit var binding: FragmentSearchNewsBinding

override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
binding = FragmentSearchNewsBinding.inflate(inflater, container, false)
return binding.root
}

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
viewModel = (activity as NewsActivity).viewModel

var job: Job? = null
binding.etSearch.addTextChangedListener { editable ->
job?.cancel()
job = MainScope().launch {
delay(SEARCH_NEWS_TIME_DELAY)
editable?.let {
if(editable.toString().isNotEmpty()) {
viewModel.searchNews(editable.toString())
}
}
}
}

viewModel.searchNews.observe(viewLifecycleOwner, Observer { response ->
when(response){
is Resource.Success -> {
hideProgressBar()
response.data?.let { newsResponse ->
newsAdapter.differ.submitList(newsResponse.articles.toList())
val totalPages = newsResponse.totalResults / Constant.QUERY_PAGE_SIZE + 2
isLastPage = viewModel.searchNewsPage == totalPages
if(isLastPage) {
binding.rvSearchNews.setPadding(0, 0, 0, 0)
}
}
}
is Resource.Error -> {
hideProgressBar()
response.message?.let { message ->
Log.e("TAG", message)
}
}
is Resource.Loading -> {
showProgressBar()
}
}
})
}

private fun hideProgressBar() {
binding.paginationProgressBar.visibility = View.INVISIBLE
isLoading = false
}

private fun showProgressBar() {
binding.paginationProgressBar.visibility = View.VISIBLE
isLoading = true
}

private fun setupRecyclerView(){
newsAdapter = NewsAdapter()
binding.rvSearchNews.apply {
adapter = newsAdapter
layoutManager = LinearLayoutManager(activity)
addOnScrollListener(this@SearchNewsFragment.scrollListener)
}
}
}

Bước 7: 





Nhận xét