[Kotlin]Retrofit+Glideで楽天APIを叩いて人気書籍を一覧表示する

Retrofitを利用してWEB APIを叩いて情報を取得するといった記事はたくさんありますが、自分が個人開発で作成しているアプリ「[書籍管理ができる巻数メモ](https://play.google.com/store/apps/details?id=com.highcom.comicmemo)」でも取り入れてみようと思い実装してみました。
この機能を実現するまでに参考にした情報と、出来上がったコードについて解説してみようと思います。

# 完成イメージ
Androidアプリとしての画面の完成イメージは以下です。
<img width="500" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/545703/9a07dd6f-2898-104f-2c06-81957c5c7152.png">
楽天APIを叩いて取得した情報から、画像とタイトルをGridViewで一覧表示しています。

# 参考にした情報
そもそも、Androidでネットワーク通信をして何か表示するってどうやるんだ？全然わからん。って状態から色々調べました。
「Android HTTP通信」とか「Android API 叩く」とか。そもそもをを理解する上で、色々と検索した結果、以下のQiitaの記事がとても参考になりました。

https://qiita.com/zaburo/items/8e44f80313ec7f795c53

とりあえず、ネットワーク通信をする際には、OkHttpライブラリが便利なのか、ふむふむ。

https://qiita.com/riversun/items/5f78d47a3d95f857d34f

OkHttp以外にも色々なライブラリがあり、通信でやり取りするデータはJSONでそれをパースする便利なライブラリがあるのか、ふむふむ。

https://qiita.com/yonce/items/eb4ba7cf38e52be6e16f

ネットワーク通信するならさらに便利なRetrofitというライブラリがあり、MVVMで実装するとこんなふうに書けるのか、ふむふむ。

https://qiita.com/wakiwaki/items/82f8dfff927ca5b9522f

楽天APIは簡単に使えそうだな、書籍の検索も出来るし良さそうだな、ふむふむ。

といった感じで色々と調査した結果は以下。
- 楽天APIは登録とか簡単で使いやすそう
- ネットワーク通信をするならRetrofitが良さげ

# 取得したURLから画像の表示方法がわからない
ここまで調べた結果、ネットワーク通信をして情報を取得するところまでは分かったが、パラメータに含まれるURLから画像を取得して表示する方法がまだ分からず、そこらへんも色々調べました。
結果、GoogleのCodeLabでチュートリアルがあり、ライブラリの使い方の解説も丁寧に順を追って書かれているため非常に参考になりました。

https://developer.android.com/codelabs/basic-android-kotlin-training-internet-images?hl=ja#3

画像を表示するためのライブラリには、CoilやGlideがあることが分かりました。

以降からは、自分のアプリに取り入れるまでの手順と実装について説明していきたいと思います。

# 楽天APIを利用出来るようにRakuten Developersアカウントを作成しておく
実装を始める前に、楽天APIを利用出来るようにアカウントを作成しておきます。

https://webservice.rakuten.co.jp/

ここからアカウントを作成できます。

https://wp-ap.net/help/rakuten-appli-id-affili-id/

手順はこのサイトが参考になりました。

https://webservice.rakuten.co.jp/documentation/books-book-search

今回は、楽天ブックス書籍検索APIを利用します。

# 実装したコードの解説
以上の色々な調査結果を踏まえて、自分のアプリに組み込む方針も決まったので、実装したコードについて解説していきたいと思います。
## `build.gradle`にRetrofitとGlideを追加
```gradle
dependencies {
    def retrofitVersion = '2.4.0'
    def version_glide = "4.8.0"
    implementation "com.squareup.retrofit2:retrofit:$retrofitVersion"
    implementation "com.squareup.retrofit2:adapter-rxjava:$retrofitVersion"
    implementation "com.squareup.retrofit2:converter-gson:$retrofitVersion"
    implementation 'com.squareup.okhttp3:logging-interceptor:3.8.1'
    implementation "com.github.bumptech.glide:glide:$version_glide"
}
```

# レスポンスデータ用のdata classを作成する
楽天APIのレスポンスデータをJsonからパースした後のdata classを定義します。
Rakuten Developersに登録すると、専用ページからAPIテストフォームを入力してレスポンスデータを確認する事ができるので、そのJSONデータをインプットとして、Android Studioプラグインの「JSON To Kotlin Class」を利用すると、自動でdata classを生成してくれます。

https://qiita.com/kaleidot725/items/5130de794d892303f0ad

生成した結果が以下。

```kotlin
data class ItemX(
    val affiliateUrl: String,
    val author: String,
    val authorKana: String,
    val availability: String,
    val booksGenreId: String,
    val chirayomiUrl: String,
    val contents: String,
    val discountPrice: Int,
    val discountRate: Int,
    val isbn: String,
    val itemCaption: String,
    val itemPrice: Int,
    val itemUrl: String,
    val largeImageUrl: String,
    val limitedFlag: Int,
    val listPrice: Int,
    val mediumImageUrl: String,
    val postageFlag: Int,
    val publisherName: String,
    val reviewAverage: String,
    val reviewCount: Int,
    val salesDate: String,
    val seriesName: String,
    val seriesNameKana: String,
    val size: String,
    val smallImageUrl: String,
    val subTitle: String,
    val subTitleKana: String,
    val title: String,
    val titleKana: String
)

data class Item(
    val Item: ItemX
)

data class RakutenBookData(
    val GenreInformation: List<Any>,
    val Items: List<Item>,
    val carrier: Int,
    val count: Int,
    val first: Int,
    val hits: Int,
    val last: Int,
    val page: Int,
    val pageCount: Int
)
```

## 楽天APIをRetrofitで呼び出すApiServiceクラスの実装
Retrofitを利用して楽天APIをHTTP GETで呼び出す処理は以下のように実装できます。
```RakutenApiService.kt
private const val baseApiUrl = "https://app.rakuten.co.jp/services/api/"

private val httpLogging = HttpLoggingInterceptor().setLevel(HttpLoggingInterceptor.Level.BODY)
private val httpClientBuilder = OkHttpClient.Builder().addInterceptor(httpLogging)

private val retrofit = Retrofit.Builder()
    .addConverterFactory(GsonConverterFactory.create())
    .baseUrl(baseApiUrl)
    .client(httpClientBuilder.build())
    .build()

/**
 * 楽天APIサービスインターフェース
 */
interface RakutenApiService {
    @GET("BooksBook/Search/20170404?format=json&booksGenreId=001001&sort=-releaseDate&hits=30")
    fun items(@Query("applicationId") appId: String): retrofit2.Call<RakutenBookData>
}

/**
 * 楽天API
 */
object RakutenApi {
    val retrofitService: RakutenApiService by lazy { retrofit.create(RakutenApiService::class.java)}
}

```
何点かポイントを解説すると
```kotlin
private val retrofit = Retrofit.Builder()
    .addConverterFactory(GsonConverterFactory.create())
```
この指定で、gsonライブラリを利用して、json形式のデータからdata classへパースするように指定できます。
```kotlin
    @GET("BooksBook/Search/20170404?format=json&booksGenreId=001001&sort=-releaseDate&hits=30")
    fun items(@Query("applicationId") appId: String): retrofit2.Call<RakutenBookData>
```
楽天書籍APIではパラメータを指定できるので、フォーマットはjson形式、ジャンルはコミック、発売日の新しい順にソート、30件分表示を指定しています。また、`@Query("applicationId")`この指定により、引数でもらったアプリIDをパラメータに指定しています。

## ApiServiceを呼び出すViewModelの実装
ApiServiceを利用して、レスポンスがきたらLiveDataを利用してレスポンスデータを通知するViewModelクラスの実装は以下となります。
```RakutenBookViewModel.kt
enum class RakutenApiStatus { LOADING, ERROR, DONE }

/**
 * 楽天書籍検索ViewModel
 *
 * @property appId 楽天API利用アプリケーションID
 */
class RakutenBookViewModel(private val appId: String) : ViewModel() {
    @Suppress("UNCHECKED_CAST")
    class Factory(
        private val id: String
    ) : ViewModelProvider.NewInstanceFactory() {
        override fun <T : ViewModel?> create(modelClass: Class<T>): T {
            return RakutenBookViewModel(appId = id) as T
        }
    }

    // 楽天APIのステータスを保持する内部変数
    private val _status = MutableLiveData<RakutenApiStatus>()
    // 楽天APIのステータス
    val status: LiveData<RakutenApiStatus>
        get() = _status

    // 楽天APIのレスポンスデータを保持する内部変数
    private val _bookData = MutableLiveData<RakutenBookData>()
    // 楽天APIのレスポンスデータ
    val bookData: LiveData<RakutenBookData>
        get() = _bookData

    init {
        getRakutenBookData(appId)
    }

    /**
     * 楽天APIを利用した書籍データ取得処理
     *
     * @param appId 楽天API利用アプリケーションID
     */
    private fun getRakutenBookData(appId: String) {
        viewModelScope.launch {
            _status.value = RakutenApiStatus.LOADING
            RakutenApi.retrofitService.items(appId).enqueue(object : retrofit2.Callback<RakutenBookData> {
                override fun onFailure(call: retrofit2.Call<RakutenBookData>?, t: Throwable?) {
                    _status.value = RakutenApiStatus.ERROR
                }

                override fun onResponse(call: retrofit2.Call<RakutenBookData>?, response: retrofit2.Response<RakutenBookData>) {
                    if (response.isSuccessful) {
                        response.body()?.let {
                            _bookData.value = response.body()
                            _status.value = RakutenApiStatus.DONE
                        }
                    }
                }
            })
        }
    }
}
```
この実装でのポイントは以下。
```kotlin
     viewModelScope.launch {
            RakutenApi.retrofitService.items(appId).enqueue(object : retrofit2.Callback<RakutenBookData> {

```
`viewModelScope.launch`により、ViewModelのライフサイクルで呼び出します。
`RakutenApi.retrofitService.items(appId).enqueue`により、ApiServiceのメソッドを非同期で呼び出します。

## データを一覧表示するためのxmlレイアウトの定義
楽天APIで取得した書籍データを一覧表示するためのレイアウトを以下のように定義します。
```activity_rakuten_book.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"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="@color/sirahanairo"
    tools:context=".RakutenBookActivity">

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/book_item_grid_view"
        android:scrollbars="vertical"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginBottom="50dp"
        app:layoutManager="androidx.recyclerview.widget.GridLayoutManager"
        app:spanCount="3"
        tools:ignore="MissingConstraints" />

</androidx.constraintlayout.widget.ConstraintLayout>
```
```grid_book_item.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.cardview.widget.CardView
    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"
    app:cardCornerRadius="6dp"
    app:cardUseCompatPadding="true">
    <LinearLayout
        android:orientation="vertical"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        tools:ignore="UseCompoundDrawables">
        <ImageView
            android:id="@+id/item_image_view"
            android:layout_width="match_parent"
            android:layout_height="160dp" />

        <TextView
            android:id="@+id/item_text_view"
            android:textSize="12sp"
            android:textColor="@android:color/black"
            android:textAlignment="center"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"/>
    </LinearLayout>
</androidx.cardview.widget.CardView>
```
RecyclerViewを利用して一覧で表示し、１つ１つのアイテムはCardViewのレイアウトを利用するようにしています。

## 一覧表示をするActivityクラスの実装
ViewModelを利用して取得した書籍データを上記のxmlレイアウトを利用して一覧で表示する実装は以下のようになります。
取得した書籍データには、書籍の画像URLがあるため、Glideライブラリを利用して画像を表示するようにしています。
```BookItemViewHolder.kt
class BookItemViewHolder(private var binding: GridBookItemBinding): RecyclerView.ViewHolder(binding.root) {
    fun bind(item: Item) {
        val imgUri = item.Item.mediumImageUrl.toUri().buildUpon().scheme("https").build()
        Glide.with(binding.itemImageView.context)
            .load(imgUri)
            .apply(
                RequestOptions()
                .placeholder(R.drawable.loading_animation)
                .error(R.drawable.ic_baseline_broken_image_24))
            .into(binding.itemImageView)
        binding.itemTextView.text = item.Item.title
    }
}
```
次に、上記のViewHolderクラスをバインドするAdapterクラスは以下です。
```BookDataGridItemAdapter.kt
class BookDataGridItemAdapter : ListAdapter<Item, BookItemViewHolder>(DiffCallback) {
    companion object DiffCallback : DiffUtil.ItemCallback<Item>() {
        override fun areItemsTheSame(oldItem: Item, newItem: Item): Boolean {
            return oldItem === newItem
        }

        override fun areContentsTheSame(oldItem: Item, newItem: Item): Boolean {
            return oldItem.Item.title == newItem.Item.title
        }
    }

    override fun onCreateViewHolder(parent: ViewGroup,
                                    viewType: Int): BookItemViewHolder {
        return BookItemViewHolder(GridBookItemBinding.inflate(LayoutInflater.from(parent.context)))
    }

    override fun onBindViewHolder(holder: BookItemViewHolder, position: Int) {
        val item = getItem(position)
        holder.bind(item)
    }
}
```
最後に、上記のAdapterクラスを利用して、一覧表示するためのActivityクラスは以下です。
```RakutenBookActivity.kt
class RakutenBookActivity : AppCompatActivity() {

    private lateinit var binding: ActivityRakutenBookBinding

    private val viewModel: RakutenBookViewModel by lazy {
        val appId = getString(R.string.rakuten_app_id)
        val factory = RakutenBookViewModel.Factory(appId)
        ViewModelProvider(this, factory)[RakutenBookViewModel::class.java]
    }

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

        val recyclerView = binding.bookItemGridView
        val itemAdapter = BookDataGridItemAdapter()

        recyclerView.adapter = itemAdapter

        // 楽天書籍データを監視
        viewModel.bookData.observe(this, Observer<RakutenBookData> {
            val items = mutableListOf<Item>()
            val res = it.Items.iterator()
            for (item in res) {
                items.add(item)
            }

            itemAdapter.submitList(items)
        })
    }
}
```
ポイントとなる部分は以下です。
```kotlin
        // 楽天書籍データを監視
        viewModel.bookData.observe(this, Observer<RakutenBookData> {
            val items = mutableListOf<Item>()
            val res = it.Items.iterator()
            for (item in res) {
                items.add(item)
            }

            itemAdapter.submitList(items)
        })
```
`viewModel.bookData.observe`で、ViewModelがApiServiceを呼び出して返ってくるレスポンスデータを監視しています。
`itemAdapter.submitList(items)`で、取得したデータをリスト化した`item`オブジェクトで更新する事で画面に反映されます。

# さいごに
説明は以上となります！
実装部分に着目した解説となっていますので、利用しているresourceファイルについては省略しています。このアプリのソースコードはGitHubで公開していますので、全体のソースコードが知りたい方は以下を参考にして頂ければと思います。

https://github.com/highcom/ComicMemo