自分自身の設計の勉強を兼ねて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に関心がありません。
実装完成イメージ
テキストフィールドに検索したいタグを入力して、取得された記事をRecyclerViewで表示するだけの簡単なアプリです。

使用技術
Android Studio:4.2.1
# ライブラリ
Retrofit
OkHttp
Gson
RxJava2
Groupie(RecyclerViewを手軽に実装できるようにする)
ライブラリ準備
適宜ライブラリのバージョンを確認して導入してください。
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の作成
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の設定をしていきます。
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リクエストを作成します。
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をたたいてデータを取得する部分の実装をしていきます。(データの取得や保存といったデータにアクセスするためのクラスをここで定義します。)
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を作成します。
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
を使っているため、ここら辺を学習してからみていただいた方が理解しやすいかと思います。
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を叩かないように制御を入れています。

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
}
レイアウトファイル(一応)
<?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>
<?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の記事を取得するアプリを作成しました。
正直、ちょっと怪しい部分もあるのでご指摘いただけたら幸いです。