RecyclerView(ListAdapter) インターネットからJSONデータを取得し画像表示

  • 投稿者:
  • 投稿カテゴリー:その他

概要

Retrofitによりインターネット接続
指定のURLよりJSONを取得しmoshiにてデータの中のidとurlをリスト化して取得
取得したリストのurlをフラグメント中のリサイクラービューのバインドデータにセット
カスタムバインディングアダプタを作成し取得したリストのurlをリサイクラービューへ渡す
リサイクラービューはListAdapterを使用
ListAdapterのonBindViewHolderでスクロールした位置のURL文字列をxmlレイアウトのバインディングデータにセット
カスタムバインディングアダプタを作成しurl文字列をインプットしてImageViewを出力
画像のダウンロードと表示はCoilライブラリを利用

環境設定

Retrofitを追加する
合わせてライブラリで必要になるJava8言語機能を追加する

Projectのbuild.gradleのリポジトリ確認
googleとjcenterがあること
buildscript {
….
repositories {
google()
jcenter()
}
….
}


appのbuild.gradleにライブラリを追加しJava8機能のサポート確認

android {

compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}

kotlinOptions {
jvmTarget = ‘1.8’
}
}

dependencies {
// Retrofit
implementation “com.squareup.retrofit2:retrofit:2.9.0”
implementation “com.squareup.retrofit2:converter-scalars:2.9.0”
}

Retrofit,moshi,Coilを追加
合わせてライブラリで必要になるJava8言語機能を追加する

Projectのbuild.gradleのリポジトリ確認
googleとjcenterがあること
buildscript {
….
repositories {
google()
jcenter()
mavenCentral()
}
….
}

appのbuild.gradleにライブラリを追加
合わせてJava8機能のサポート確認

plugins {
id ‘com.android.application’
id ‘kotlin-android’
id ‘kotlin-kapt’
}
android {

compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}

権限の追加

manifests/AndroidManifest.xmlの タグの直前に以下を追加
<uses-permission android:name=”android.permission.INTERNET” />

サンプル

レイアウトxml

画面遷移なし
1つのアクティビティに1つのフラグメントのみ
リサイクラービューのレイアウトはイメージビュー1つのみ。grid_view_item.xmlに記載。
ViewModelのMutableLiveDataを用いる。
フラグメント上のRecyclerViewとImageViewはBindingAdapters.ktにて定義したカスタムバインディングアダプタを使用
 app:listData→@BindingAdapter(“listData”)
 app:marsApiStatus→@BindingAdapter(“marsApiStatus”)
 app:imageUrl→@BindingAdapter(“imageUrl”)

<?xml version="1.0" encoding="utf-8"?>
<androidx.fragment.app.FragmentContainerView
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/overviewFragment"
    android:name="com.example.android.marsphotos.overview.OverviewFragment"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:layout="@layout/fragment_overview" />
<?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>
        <variable
            name="viewModel"
            type="com.example.android.marsphotos.overview.OverviewViewModel" />
    </data>

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        tools:context="com.example.android.marsphotos.overview.OverViewFragment">
        <!--
            app:listData要素は、カスタムバインディングアダプタ
            BindingAdapters.ktで定義した
            @BindingAdapter("listData")の関数(bindRecyclerView)
            から戻されるイメージデータ
            入力する値はviewModelのLiveData
        -->
        <androidx.recyclerview.widget.RecyclerView
            android:id="@+id/photos_grid"
            android:layout_width="0dp"
            android:layout_height="0dp"
            android:padding="6dp"
            android:clipToPadding="false"
            app:layoutManager="androidx.recyclerview.widget.GridLayoutManager"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintLeft_toLeftOf="parent"
            app:layout_constraintRight_toRightOf="parent"
            app:layout_constraintTop_toTopOf="parent"
            app:listData="@{viewModel.photos}"
            app:spanCount="2"
            tools:itemCount="16"
            tools:listitem="@layout/grid_view_item" />
        <!--
            app:marsApiStatus要素は、、カスタムバインディングアダプタ
            BindingAdapters.ktで定義した
            @BindingAdapter("listData")の関数(bindRecyclerView)
            から戻されるイメージデータ
            入力する値はviewModelのLiveData
        -->
        <ImageView
            android:id="@+id/status_image"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintLeft_toLeftOf="parent"
            app:layout_constraintRight_toRightOf="parent"
            app:layout_constraintTop_toTopOf="parent"
            app:marsApiStatus="@{viewModel.status}" />
    </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"
    xmlns:tools="http://schemas.android.com/tools">
    <data>
        <variable
            name="photo"
            type="com.example.android.marsphotos.network.MarsPhoto" />
    </data>
    <ImageView
        android:id="@+id/mars_image"
        android:layout_width="wrap_content"
        android:layout_height="170dp"
        android:scaleType="fitXY"
        android:adjustViewBounds="true"
        android:padding="2dp"
        app:imageUrl="@{photo.imgSrcUrl}"
        tools:src="@tools:sample/backgrounds/scenic"/>
</layout>

カスタムバインディングアダプタ

MainActivity.ktと同じフォルダにBindingAdapterアノテーションと関数だけのファイルを作成し、カスタムバインディングアダプターを定義する。
書式は、@BindingAdapter(カスタムバインディングアダプタ名)
fun 任意の関数名(
アウトプットオブジェクトの変数名 : xmlレイアウトオブジェクトの型,
インプットデータ : xmlレイアウトの右辺のデータ型 ){
処理内容
}
関数内のアウトプットオブジェクトはxmlレイアウトのビューオブジェクト
インプットデータはxmlレイアウトの右辺で指定した値
処理内容はCoilで画像を取得したり、オブジェクトの表示・非表示の切替たり、RecyclerViewへのデータ受渡など様々。

import android.view.View
import android.widget.ImageView
import androidx.core.net.toUri
import androidx.databinding.BindingAdapter
import androidx.recyclerview.widget.RecyclerView
import coil.load
import com.example.android.marsphotos.network.MarsPhoto
import com.example.android.marsphotos.overview.MarsApiStatus
import com.example.android.marsphotos.overview.PhotoGridAdapter

/**
 * カスタムバインディングアダプタの作成
 * MainActivity.ktと同じフォルダにアノテーションと関数だけの
 * ファイルを作成しカスタムバインディングアダプターを定義する。
 *
 * 書式は、@BindingAdapter(カスタムバインディングアダプタ名)
 * fun 任意の関数名(
 *     アウトプットオブジェクトの変数名 : xmlレイアウトオブジェクトの型,
 *     インプットデータ : xmlレイアウトの右辺のデータ型 ){
 *     処理内容
 * }
 */

// grid_view_item.xmlで定義したapp:imageUrlのバインディングアダプタ
@BindingAdapter("imageUrl")
fun bindImage(imgView: ImageView, imgUrl: String?) {
    // URLが存在する場合は画像を取得しImageViewに表示する
    imgUrl?.let {
        // URL文字列をUriに変換しその上にスキームhttpsを付加
        val imgUri = imgUrl.toUri().buildUpon().scheme("https").build()
        // Coilで画像を取得してImageViewにセットする(たったこれだけ!)
        imgView.load(imgUri){
            placeholder(R.drawable.loading_animation)   // 処理中の画像
            error(R.drawable.ic_broken_image)   // エラー時の画像
        }
    }
}

// fragment_overview.xmlで定義したapp:listDataのバインディングアダプタ
@BindingAdapter("listData")
fun bindRecyclerView(recyclerView: RecyclerView,
                     data: List<MarsPhoto>?) {
    // 出力するrecyclerViewのadapterをListAdapter型の
    // PhotoGridAdapterにキャストし変数に代入
    // ※フラグメント側で binding.photosGrid.adapter = PhotoGridAdapter()
    // としているので、ここでもadapterの型をPhotoGridAdapterとしておく
    val adapter = recyclerView.adapter as PhotoGridAdapter
    // RecycleViewに表示するListデータ(data)を登録
    adapter.submitList(data)
}

// fragment_overview.xmlで定義したapp:marsApiStatusのバインディングアダプタ
@BindingAdapter("marsApiStatus")
fun bindStatus(statusImageView: ImageView,
               status: MarsApiStatus?) {
    // statusの値に応じてViewを表示・非表示を切替しイメージを表示する
    when (status) {
        MarsApiStatus.LOADING -> {
            statusImageView.visibility = View.VISIBLE
            statusImageView.setImageResource(R.drawable.loading_animation)
        }
        MarsApiStatus.ERROR -> {
            statusImageView.visibility = View.VISIBLE
            statusImageView.setImageResource(R.drawable.ic_connection_error)
        }
        MarsApiStatus.DONE -> {
            statusImageView.visibility = View.GONE
        }
    }
}

アクティビティ

レイアウトxmlで指定したフラグメントを起動するだけ

import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
    }
}

フラグメント

ビューモデルとしてOverviewViewModelを定義
フラグメント側はデータバインディングを設定
RecyclerViewのadapterを別途作成したListAdapter型のPhotoGridAdapterとする。
※RecyclerViewの別例はRecyclerView.Adapter型を使用した

import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import com.example.android.marsphotos.databinding.FragmentOverviewBinding
import com.example.android.marsphotos.databinding.GridViewItemBinding

class OverviewFragment : Fragment() {

    // ビューモデル OverviewViewModelを定義
    private val viewModel: OverviewViewModel by viewModels()

    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        // fragment_overview.xmlにデータバインディングを設定
        val binding = FragmentOverviewBinding.inflate(inflater)

        // lifecycleOwnerはフラグメントとする
        binding.lifecycleOwner = this

        // xmlレイアウトの<data>タグ部分との紐づけ
        // バインディングデータの設定
        binding.viewModel = viewModel

        // リサイクラービューの@+id/photos_grid"に対して
        // adapterをPhotoGridAdapterに設定する
        binding.photosGrid.adapter = PhotoGridAdapter()

        return binding.root
    }
}

ビューモデル

データバインディング用のMutableLiveDataを設定
init処理にてインターネットからJSONデータを取得する
データの取得はMarsApiService.ktに定義したMarsApiオブジェクト(retrofit)を使う。

import android.util.Log
import android.widget.ImageView
import androidx.core.net.toUri
import androidx.databinding.BindingAdapter
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import coil.load
import com.example.android.marsphotos.network.MarsApi
import kotlinx.coroutines.launch
import com.example.android.marsphotos.network.MarsPhoto

// 定数を定義したenumクラス
enum class MarsApiStatus { LOADING, ERROR, DONE }

class OverviewViewModel : ViewModel() {

    // データバインディングのMutableLiveData変数を定義

    // ステータスはenumで定義した定数の型とする
    private val _status = MutableLiveData<MarsApiStatus>()
    val status: LiveData<MarsApiStatus> = _status

    private val _photos = MutableLiveData<List<MarsPhoto>>()
    val photos: LiveData<List<MarsPhoto>> = _photos

    init {
        // データ取得
        getMarsPhotos()
    }

    // JSONデータ取得メソッド
    private fun getMarsPhotos() {
        // launchによりコルーチン(子スレッド)を起動してデータを取得する
        viewModelScope.launch {
            _status.value = MarsApiStatus.LOADING
            try {
                // MarsApiオブジェクト(retrofitService)を
                _photos.value = MarsApi.retrofitService.getPhotos()
                _status.value = MarsApiStatus.DONE
            } catch (e: Exception) {
                // エラー処理
                _status.value = MarsApiStatus.ERROR
                _photos.value = listOf()
            }
        }
    }
}

ライブラリ関連 (retrofit,moshi)

ライブラリ関連で使用するオブジェクトをまとめたktファイルを作成(MarsApiService.kt)
moshiの設定とretrofitの設定をそれぞれ作成。
retrofitの設定はデータ変換にmoshiを利用するためMoshiConverterFactoryを使用する。
retrofitのオブジェクトは、interfaceクラス(MarsApiService)を指定して作成。この時「by lazy」で遅延初期化する(実際に利用される時に初期化)
interfaceクラスはURLのエンドポイントの指定とGETなどのリクエスト方法を定義。
合わせてinterfaceクラス内に任意の名前の関数を定義し取得するデータの型を指定。

import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory
import retrofit2.http.GET

// ベースアドレス
private const val BASE_URL = "https://android-kotlin-fun-mars-server.appspot.com"

// Moshiの設定
// (項目名変換のMoshiアノテーションを動作させるためKotlinJsonAdapterFactoryを追加)
private val moshi = Moshi.Builder()
    .add(KotlinJsonAdapterFactory())
    .build()

// Retrofitの設定
private val retrofit = Retrofit.Builder()
    // ConverterFactoryにMoshiConverterFactoryを指定
    // Converterのcreate引数はMoshiビルダーのインスタンスをセット
    .addConverterFactory(MoshiConverterFactory.create(moshi))
    .baseUrl(BASE_URL)
    .build()

// interface
interface MarsApiService {
    // getリクエストを実行。ベースURLに続いての「photos」がurlになる
    @GET("photos")
    // 任意の名前の関数を定義し取得するデータの型を指定する
    // MoshiコンバータによりJsonデータをMarsPhoto型のリストとして取得
    // suspendとする事でコルーチンからの呼出しが可能
    suspend fun getPhotos(): List<MarsPhoto>
}

// retrofitのオブジェクトを作成
// ktファイルのロード時は「by lazy」で遅延初期化(実際に利用される時に初期化)
// シングルトンオブジェクトとして1つのみインスタンス化される
object MarsApi {
    val retrofitService : MarsApiService by lazy {
        // Retrofitビルダーのcreateメソッドに
        // MarsApiServiceインターフェイスを渡す
        retrofit.create(MarsApiService::class.java)
    }
}

データクラス

moshiのデータ取得で使用する項目名を定義する。
基本はJSONの項目名と同じ名前の変数名とする
ただし、JSON側の項目名がキャメルケースでない場合は@Jsonにより別名で紐づけを定義(KotlinJsonAdapterFactoryでビルドする必要あり)

import com.squareup.moshi.Json

/*
JSONのサンプルデータ
[{"id":"424905","img_src":"http://mars.jpl.nasa.gov/msl-raw-images/msss/01000/mcam/1000MR0044631300503690E01_DXXX.jpg"},
{"id":"424906","img_src":"http://mars.jpl.nasa.gov/msl-raw-images/msss/01000/mcam/1000ML0044631300305227E03_DXXX.jpg"},
 */
data class MarsPhoto(
    // 基本はJSONの項目名と同じ名前の変数名とする。
    // ただしJSON側の項目名がキャメルケースでない場合は@Jsonにより別名で紐づけを定義する
    val id: String,
    @Json(name = "img_src") val imgSrcUrl: String
)

リストアダプタ

リサイクラービューのアダプタとしてListAdapterクラスを利用
書式は
class クラス名 : ListAdapter(C: DiffUtils.ItemCallback)
A:Object は表示するデータ
 (バインドデータよりカスタムバインディングアダプタで設定済み → adapter.submitList(data))
B:クラス内でViewHolderを定義しそのクラス名をセット
 ViewHolderは2つの抽象メソッドでも使用する
C:DiffUtils.ItemCallback は A:Objectの差分確認方法を実装したItemCallback
 クラス内にcompanion objectを作成しDiffUtil.ItemCallbackを作成
 2つの抽象メソッドが必要
ListAdapterクラス内には、前述のViewHolderとDiffUtil.ItemCallbackオブジェクトの他にonCreateViewHolderとonBindViewHolderを実装する。
onCreateViewHolder→ViewHolderをリターン
onBindViewHolder→画面スクロール時に現在のposのデータをViewHolderに渡す

import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import com.example.android.marsphotos.databinding.GridViewItemBinding
import com.example.android.marsphotos.network.MarsPhoto

/**
 * リサイクラービューのアダプタ ListAdapter
 * ListAdapterはLiveDataの変更されたレコードを読取しバインドデータを戻す
 *
 * RecyclerViewの例はRecyclerView.Adapter型だったけれど
 * この例題はRecyclerView.Adapter型の派生クラスのListAdapterを使用
 *
 * 書式は
 * class クラス名 : ListAdapter<A: Object, B: RecyclerView.ViewHolder>(C: DiffUtils.ItemCallback<A>)
 * - A:Object は表示するデータ
 * (バインドデータよりカスタムバインディングアダプタで設定済み → adapter.submitList(data))
 * - B:クラス内でViewHolderを定義しそのクラス名をセット
 * ViewHolderは2つの抽象メソッドでも使用する
 * - C:DiffUtils.ItemCallback<A> は A:Objectの差分確認方法を実装したItemCallback
 * クラス内にcompanion objectを作成しDiffUtil.ItemCallbackを作成
 * 2つの抽象メソッドが必要
 *
 * ListAdapterクラス内には、前述のViewHolderとDiffUtil.ItemCallbackオブジェクトの他に
 * onCreateViewHolderとonBindViewHolderを実装する。
 * onCreateViewHolder→ViewHolderをリターン
 * onBindViewHolder→画面スクロール時に現在のposのデータをViewHolderに渡す
 */
class PhotoGridAdapter :    ListAdapter<
                                MarsPhoto,
                                PhotoGridAdapter.MarsPhotoViewHolder
                            >(DiffCallback) {

    // 2つの要素を比較するObject 
    // ListAdapterのコンストラクタにて必要
    companion object DiffCallback : DiffUtil.ItemCallback<MarsPhoto>() {
        override fun areItemsTheSame(oldItem: MarsPhoto, newItem: MarsPhoto): Boolean {
            return oldItem.id == newItem.id
        }
        override fun areContentsTheSame(oldItem: MarsPhoto, newItem: MarsPhoto): Boolean {
            return oldItem.imgSrcUrl == newItem.imgSrcUrl
        }
    }

    // ビューホルダの定義 型はRecyclerView.ViewHolder(binding.root)
    // 引数はレイアウトファイル(grid_view_item.xml)のバインディングデータ
    // ListAdapterのコンストラクタにて必要
    class MarsPhotoViewHolder(
        private var binding: GridViewItemBinding
    ) : RecyclerView.ViewHolder(binding.root) {
        // 後述のonBindViewHolderよりコールされる
        // ViewModelのinit処理で取得したLiveDataからカレントデータを受取り、
        // ビュー側(binding)にidとurlの入った文字列データをセットする
        fun bind(MarsPhoto: MarsPhoto) {
            binding.photo = MarsPhoto
            // bind関数が呼ばれた時点で画面反映させる
            binding.executePendingBindings()
        }
    }

    // 抽象メソッド1
    // 前述したビューホルダ(MarsPhotoViewHolder)をCreateして戻す
    override fun onCreateViewHolder(
        parent: ViewGroup,  // ViewGroup(リサイクラービューのレイアウトxmlのコンテキスト)
        viewType: Int       // 異なるビュータイプがある時に利用
    ): PhotoGridAdapter.MarsPhotoViewHolder {
        return MarsPhotoViewHolder(GridViewItemBinding.inflate(
            LayoutInflater.from(parent.context)))
    }

    // 抽象メソッド2
    // 画面スクロール時に現在のposのデータを受渡する
    override fun onBindViewHolder(
        holder: PhotoGridAdapter.MarsPhotoViewHolder,
        position: Int) {
        // 現在のposのデータを取得
        val marsPhoto = getItem(position)
        // 取得データをMarsPhotoViewHolderのbindメソッドに渡す
        holder.bind(marsPhoto)
    }
}

アダプタにRecyclerView.Adapterを使ったものはRecyclerView (RecyclerView.Adapter)を参照

トレーニング > KOTLIN を用いた ANDROID の基本 > インターネット > データを取得して表示する > インターネットから画像を読み込んで表示する > 3. インターネット画像を表示する