【Kotlin】MVVM + RxJava + RetrofitでQiitaの記事を取得するアプリを作成してみた

自分自身の設計の勉強を兼ねてMVVMでQiitaの記事を取得するだけの簡単なアプリを作成しました。（https://qiita.com/api/v2/docs#%E6%8A%95%E7%A8%BF）

## MVVMとは

- Model
- View
- ViewModel

の3つから構成されます。

View層 → ViewModel層 → Model層といったような依存関係があります。
Model層はViewModelやViewに関心がなく（Viewの種類に関わらず同じ処理を行う→Viewに依存しない）、ViewModel層はViewに関心がありません。

参考：[【Android】2020年からの MVVM【実践】](https://qiita.com/sdkei/items/a48ae24536562ed000b3)

## 実装完成イメージ
テキストフィールドに検索したいタグを入力して、取得された記事をRecyclerViewで表示するだけの簡単なアプリです。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/484337/b6593f5a-6fda-0e13-765e-6abfbbc068ac.jpeg" width=50%>


## 使用技術

```
Android Studio:4.2.1

# ライブラリ
Retrofit
OkHttp
Gson
RxJava2
Groupie(RecyclerViewを手軽に実装できるようにする）
```

## ライブラリ準備
適宜ライブラリのバージョンを確認して導入してください。

```build.gradle
    implementation 'com.xwray:groupie:2.9.0'
    implementation 'com.xwray:groupie-databinding:2.7.2'
    implementation 'com.squareup.okhttp3:okhttp:4.9.0'
    implementation "com.squareup.okhttp3:logging-interceptor:4.9.0"
    implementation 'com.squareup.retrofit2:retrofit:2.9.0'
    implementation 'com.squareup.retrofit2:converter-gson:2.9.0'
    implementation 'com.squareup.retrofit2:converter-simplexml:2.3.0'
    implementation 'com.squareup.retrofit2:adapter-rxjava2:2.3.0'
    implementation "io.reactivex.rxjava2:rxandroid:2.1.1"
    implementation "io.reactivex.rxjava2:rxkotlin:2.1.0"
    implementation "io.reactivex.rxjava2:rxjava:2.2.9"
    implementation 'com.google.code.gson:gson:2.8.6'
```

## Modelの作成
```Article.kt
package com.google.codelab.qiita_mvvm.model

import com.google.gson.annotations.SerializedName

data class Article(
    @SerializedName("likes_count")
    val likeCount: Int,
    val title: String,
    @SerializedName("updated_at")
    val updateDate: String,
    val url: String
)
```

このアプリでは記事のタイトルとLGTM数しか表示していないので、下2つはなくても大丈夫です。
また、他に表示したいデータがあったらModelに追加しておきましょう。

` @SerializedName("likes_count")`はAPI側で`likes_count`と定義されているため、Kotlin側でもそれに合わせて変数名を`likes_count`にしなくてはいけないのですが、Kotlinではスネークケースは使わないため、`@SerializedName`を使って変換しています。

## APIクライアントの作成
API通信に必要なRetrofitやOkHttpの設定をしていきます。

```ApiClient.kt
package com.google.codelab.qiita_mvvm

import com.google.gson.FieldNamingPolicy
import com.google.gson.GsonBuilder
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.adapter.rxjava2.RxJava2CallAdapterFactory
import retrofit2.converter.gson.GsonConverterFactory
import java.util.concurrent.TimeUnit

object ApiClient {
    private const val URL = "https://qiita.com/api/v2/" // BaseUrlを指定

    // Okhttp
    private val client = OkHttpClient.Builder()
        .connectTimeout(60, TimeUnit.SECONDS)
        .writeTimeout(60, TimeUnit.SECONDS)
        .readTimeout(60, TimeUnit.SECONDS)
        .addInterceptor(HttpLoggingInterceptor().apply {
            level = HttpLoggingInterceptor.Level.BODY
        })
        .build()

    private val gson = GsonBuilder()
        .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES)
        .create()

    // retrofit
    val retrofit = Retrofit.Builder()
        .baseUrl(URL)
        .addConverterFactory(GsonConverterFactory.create(gson))
        .addCallAdapterFactory(RxJava2CallAdapterFactory.create())
        .client(client)
        .build()
}
```

いくつか説明をすると
**HttpLoggingInterceptor**
HttpLoggingInterceptorを設定することでAPI通信のログを出力することができます。

```
2021-05-29 14:55:26.244 26915-27492/com.google.codelab.qiita_mvvm I/okhttp.OkHttpClient: --> GET https://qiita.com/api/v2/tags/kotlin/items?page=1&per_page=20
2021-05-29 14:55:26.245 26915-27492/com.google.codelab.qiita_mvvm I/okhttp.OkHttpClient: --> END GET
2021-05-29 14:55:26.961 26915-27492/com.google.codelab.qiita_mvvm I/okhttp.OkHttpClient: <-- 200 https://qiita.com/api/v2/tags/kotlin/items?page=1&per_page=20 (716ms)
```

**RxJava2CallAdapterFactory**
Adapter: RetrofitでRxJavaを使った値を返すようにするために必要なものです。Retrofit 単体では Single が扱えないので AdapterFactory に RxJava2CallAdapterFactory を追加します。

この流れでAPIリクエストを作成します。

```ApiRequest.kt
package com.google.codelab.qiita_mvvm

import com.google.codelab.qiita_mvvm.model.Article
import io.reactivex.Single
import retrofit2.Call
import retrofit2.Response
import retrofit2.http.GET
import retrofit2.http.Path
import retrofit2.http.Query

interface ApiRequest {
    @GET("tags/{tag_id}/items")
    fun fetchArticles(
        @Path("tag_id") tagId: String,
        @Query("page") page: Int,
        @Query("per_page") perPage: Int
    ): Single<Response<List<Article>>>
}
```

今回は、`tagId`に検索するタグのキーワードが入ります。`perPage`は一度に取得する記事の件数（今回は20件ずつ）が入ります。

`kotlin`で検索した場合のURLは以下のようになります。

```
https://qiita.com/api/v2/tags/kotlin/items?page=1&per_page=20
```

また、APIをたたいて取得したデータはRxJavaを使って処理するため、`Single`にしています。

## Repositoryの作成
`Repository`:APIをたたいてデータを取得する部分の実装をしていきます。(データの取得や保存といったデータにアクセスするためのクラスをここで定義します。)

```ArticleListRepository.kt
package com.google.codelab.qiita_mvvm.repository

import com.google.codelab.qiita_mvvm.ApiClient.retrofit
import com.google.codelab.qiita_mvvm.ApiRequest
import com.google.codelab.qiita_mvvm.model.Article
import io.reactivex.Single
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.schedulers.Schedulers

class ArticleListRepository {
    fun fetchArticles(tag: String, page: Int): Single<List<Article>> {
        return retrofit.create(ApiRequest::class.java).fetchArticles(tag, page, 20)
            .subscribeOn(Schedulers.io())
            .observeOn(AndroidSchedulers.mainThread())
            .map {
                it.body()
            }
    }
}
```

## ViewModelの作成
次にRepositoryを呼び出すViewModelを作成します。

```ArticleListViewModel.kt
package com.google.codelab.qiita_mvvm.viewModel

import android.annotation.SuppressLint
import androidx.lifecycle.ViewModel
import com.google.codelab.qiita_mvvm.repository.ArticleListRepository
import com.google.codelab.qiita_mvvm.Signal
import com.google.codelab.qiita_mvvm.model.Article
import io.reactivex.subjects.PublishSubject

class ArticleListViewModel : ViewModel() {
    private val repository = ArticleListRepository()
    val fetchArticleList: PublishSubject<List<Article>> = PublishSubject.create()
    var keyword: String? = null

    @SuppressLint("CheckResult")
    fun fetchArticles(keyword: String, page: Int) {
        repository.fetchArticles(keyword, page)
            .onErrorReturnItem(ArrayList())
            .subscribe { articleList ->
                fetchArticleList.onNext(articleList)
            }

    }
}
```

`fetchArticles()`内でRepositoryで取得した記事を受け取り、それをRxJavaを使ってデータを取得したことを通知しています。
これをView側（今回はFragment)で呼び出してあげることで記事の一覧を受け取ることができます。

## Viewの作成
今回はフラグメントで作成します。
ここでは`DataBinding`と`Groupie`を使っているため、ここら辺を学習してからみていただいた方が理解しやすいかと思います。

```ArticleListFragment.kt
package com.google.codelab.qiita_mvvm.view

import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.fragment.app.Fragment
import androidx.lifecycle.ViewModelProvider
import androidx.recyclerview.widget.RecyclerView
import com.google.codelab.qiita_mvvm.R
import com.google.codelab.qiita_mvvm.databinding.FragmentArticleListBinding
import com.google.codelab.qiita_mvvm.model.Article
import com.google.codelab.qiita_mvvm.viewModel.ArticleListViewModel
import com.xwray.groupie.GroupAdapter
import com.xwray.groupie.GroupieViewHolder
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.rxkotlin.subscribeBy

class ArticleListFragment : Fragment() {
    private lateinit var binding: FragmentArticleListBinding
    private lateinit var viewModel: ArticleListViewModel
    private val groupAdapter = GroupAdapter<GroupieViewHolder>()
    private val articleList: MutableList<Article> = ArrayList()
    private var isMoreLoad = true
    private var currentPage = 1

    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        binding = FragmentArticleListBinding.inflate(inflater)

        val factory: ViewModelProvider.Factory = ViewModelProvider.NewInstanceFactory()
        viewModel =
            ViewModelProvider(requireActivity(), factory).get(ArticleListViewModel::class.java)

        binding.viewModel = viewModel

        return binding.root
    }

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

        binding.recyclerView.adapter = groupAdapter

        // 検索ボタンを押下した際に実行
        binding.button.setOnClickListener {
             articleList.clear()
             if (binding.keywordEditText.text.isNotEmpty()) {
                 viewModel.keyword = binding.keywordEditText.text.toString()
                 currentPage = 1
                 viewModel.fetchArticles(viewModel.keyword.toString(), currentPage)
             } else {
                 Toast.makeText(requireContext(), R.string.no_text, Toast.LENGTH_SHORT).show()
            }

        viewModel.fetchArticleList
            .observeOn(AndroidSchedulers.mainThread())
            .subscribeBy { articles ->
                if (articles.size < 20) {
                    isMoreLoad = false
                }
                // 記事が0件の場合トーストを表示
                if (articles.isEmpty()) {
                    Toast.makeText(requireContext(), R.string.no_articles, Toast.LENGTH_SHORT).show()
                } else {
                    articleList.addAll(articles)
                    groupAdapter.update(articleList.map {
                        ArticleListItemFactory(
                            it,
                            requireContext()
                        )
                    })
                }
            }

        // スクロールして最下部に到達したことを検知して、再度APIを叩くようにしている
        binding.recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
            override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
                super.onScrolled(recyclerView, dx, dy)
                if (!recyclerView.canScrollVertically(1) && isMoreLoad) {
                    currentPage += 1
                    viewModel.keyword?.let { viewModel.fetchArticles(it, currentPage) }
                }
            }
        })
    }

}
```

**viewModel.fetchArticleList**
ViewModelに定義した`fetchArticleList`の値が更新されたらここの中の処理が実行されます。
ここでは、記事をList型で受け取っており、それを`Groupie`でRecyclerViewに更新をかけています。

**binding.recyclerView.addOnScrollListener**
ここでは、RecyclerViewのスクロールを検知し、最下部に到達したら再度APIを叩くようにしています。
また、`isMoreLoad`で記事を最後まで取得したらそれ以上APIを叩かないように制御を入れています。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/484337/02524948-e590-fd68-f235-7caa0c32f51b.gif" width=30%>


```ArticleListItemFactory.kt
package com.google.codelab.qiita_mvvm.view

import android.content.Context
import com.google.codelab.qiita_mvvm.R
import com.google.codelab.qiita_mvvm.databinding.CellArticleBinding
import com.google.codelab.qiita_mvvm.model.Article
import com.xwray.groupie.Item
import com.xwray.groupie.databinding.BindableItem

class ArticleListItemFactory(private val article: Article, val context: Context) :
    BindableItem<CellArticleBinding>() {
    override fun getLayout(): Int = R.layout.cell_article

    override fun bind(viewBinding: CellArticleBinding, position: Int) {
        viewBinding.titleText.text = article.title
        viewBinding.likesCountText.text =
            context.getString(R.string.LGTM).plus(article.likeCount.toString())
    }

    override fun isSameAs(other: Item<*>): Boolean =
        (other as? ArticleListItemFactory)?.article?.title == article.title
}
```

## レイアウトファイル（一応）
```fragment_article_list.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"
    tools:context=".view.ArticleListFragment">

    <data>

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

        <variable
            name="viewModel"
            type="com.google.codelab.qiita_mvvm.viewModel.ArticleListViewModel" />
    </data>

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent">

        <EditText
            android:id="@+id/keyword_edit_text"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginTop="16dp"
            android:autofillHints="keyword"
            android:ems="10"
            android:hint="keyword(Android etc...)"
            android:inputType="textPersonName"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent" />

        <ImageView
            android:id="@+id/button"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            app:layout_constraintBottom_toBottomOf="@+id/keyword_edit_text"
            app:layout_constraintStart_toEndOf="@+id/keyword_edit_text"
            app:layout_constraintTop_toTopOf="@+id/keyword_edit_text"
            app:srcCompat="@drawable/ic_baseline_search_24" />

        <androidx.recyclerview.widget.RecyclerView
            android:id="@+id/recycler_view"
            android:layout_width="0dp"
            android:layout_height="0dp"
            android:layout_marginTop="16dp"
            app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@+id/keyword_edit_text" />

        <View
            android:id="@+id/no_articles"
            android:layout_width="0dp"
            android:layout_height="0dp"
            android:layout_marginTop="16dp"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@+id/keyword_edit_text" />

    </androidx.constraintlayout.widget.ConstraintLayout>
</layout>
```

```cell_article.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"
    >

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content">

        <TextView
            android:id="@+id/title_text"
            android:layout_width="300dp"
            android:layout_height="wrap_content"
            android:layout_marginTop="16dp"
            android:layout_marginBottom="16dp"
            android:layout_marginStart="8dp"
            android:maxLines="2"
            android:text="@string/title"
            android:textAppearance="@style/TextAppearance.AppCompat.Large"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent" />

        <TextView
            android:id="@+id/likes_count_text"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginEnd="16dp"
            android:layout_marginBottom="8dp"
            android:text="@string/likes_count"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintTop_toTopOf="parent" />
    </androidx.constraintlayout.widget.ConstraintLayout>
</layout>
```

## まとめ
今回は、MVVM + Retrofit + RxJavaでQiitaの記事を取得するアプリを作成しました。
正直、ちょっと怪しい部分もあるのでご指摘いただけたら幸いです。