RecyclerViewとCoroutineで非同期フォトギャラリーを作る

2021.07.25 ソート順をDATE_ADDEDに変更しました。android-kotlin-extensionsを使ってViewにアクセスしていた部分を、DataBindingオブジェクト経由でアクセスするように変更しました。

前回は、最小限の実装でフォトギャラリー機能を実装し、MediaStore APIの基本的な使い方を理解しました。ただ、パフォーマンスについて一切考慮していなかったため、実際に動かすとRecyclerViewがまともにスクロールできないくらい重たい動作になっていました。そこで今回は、MediaStoreへのアクセスや画像の読み込みを、KotlinのCoroutineを使って非同期に行い、より実用的なフォトギャラリーにしていきます。

今回もソースコードはGithubに載せていますので参考にしてください。

それでは早速説明していきます。

機能

以下の機能を実装することにします。

  • 端末内の画像を新しい順に表示する。
  • 選択した画像のURIを取得する。

モジュール構成

実際のアプリに組み込んで再利用することを考慮して、FragmentにRecyclerViewを配置します。また、選択した画像のURIは、ViewModel経由でFragmentからActivityに渡します。さらに、Activityは呼び出し元のActivityにURIを返します。Activity間のやり取りは、ActivityResultContractを使ってカプセル化します。

  • MainActivityの役割
    • ボタンクリックでPhotoGalleryActivityを起動する。
    • PhotoGalleryActivityから受け取ったUriImageViewにセットし、画像を表示する。
  • PhotoGalleryActivityの役割
    • PhotoGalleryFragmentを表示する
    • PhotoGalleryViewModelからURI選択イベントを受信したら、呼び出し元のMainActivityにURIをActivityResultContract経由で渡す。
  • PhotoGalleryFragmentの役割
    • READ_EXTERNAL_STORAGE権限を要求する。
    • 権限が拒否された場合に、メッセージを表示する。
    • 権限が承認された場合に、RecyclerViewを表示する。

準備

Manifest

端末内の他のアプリ(カメラアプリなど)が作成した画像ファイルにアクセスするため、READ_EXTERNAL_STORAGE権限が必要です。あらかじめAndroidManifestに登録しておきます。

<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />

build.gradle(app)

gradleに対してもいくつか準備をしておきます。

  • Data Bindingのための、kotlin-kaptプラグインとdataBindingenable
  • androidxのActivityFragmentを使うための、androidx.activity:activity-ktxandroidx.fragment:fragment-ktx
  • 依存関係挿入ライブラリには、Koinを使用
  • 画像の非同期読み込みには、Picassoを使用
plugins {
    ...
    id 'kotlin-kapt'
}

android {
    ...
    dataBinding {
        enabled = true
    }
}

dependencies {
    ...
    implementation 'androidx.activity:activity-ktx:1.2.2'
    implementation 'androidx.fragment:fragment-ktx:1.3.3'

    def koin_version = "2.2.2"
    implementation "org.koin:koin-android:$koin_version"
    implementation "org.koin:koin-android-scope:$koin_version"
    implementation "org.koin:koin-android-viewmodel:$koin_version"

    implementation "com.squareup.picasso:picasso:2.8"
}

ソースコード

ここからは、具体的なソースコードの説明になります。データの流れにそって説明した方が分かりやすいと思いますので、より低いレイヤーから順に説明してきます。ソースコードの全体像が見えなくなってしまったら、上に載せたモジュール構成図や、Githubのソースコードも見ながら、説明を読み進めて下さい。

写真データクラスを定義する

はじめに、フォトギャラリーの写真一枚一枚の情報を格納するデータクラス、PhotoGalleryItemを定義します。RecyclerViewの各アイテムは、このクラスの情報を描画します。現状は写真のURIのみを保持していますが、必要に応じて撮影日やフォルダなどの情報を追加することも考えられます。

data class PhotoGalleryItem(
    val uri: Uri /* 現状はURIのみ保持する */
) {
    companion object {
        /* DiffUtilの定義 */
        val DIFF_UTIL = object: DiffUtil.ItemCallback<PhotoGalleryItem>() {
            override fun areItemsTheSame(oldItem: PhotoGalleryItem, newItem: PhotoGalleryItem)
                    : Boolean {
                return oldItem.uri == newItem.uri
            }

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

    }
}

RecyclerViewAdapterが利用するDiffUtilも、このクラス内に定義しています。必ずしもこのクラス内で定義する必要はなく、Adapter側に定義するサンプルもよく見かけます。ただ、将来の仕様変更でRecyclerViewに画像以外の情報も一緒に表示するようになった場合などは、PhotoGalleryItemクラスにURI以外の情報も保持するようになるでしょう。その場合、DiffUtilも同時に変更することになりますので、同じ場所で定義しておくとスマートだと私は思います。

さて、そのDiffUtilですが、2つの関数をオーバーライドする必要があります。areItemsTheSame()と、areContentsTheSame()です。

areItemsTheSame()は、RecyclerViewAdapterが2つの要素を比較する際に、最初に呼ばれます。2つのオブジェクトが同じアイテムを表しているかどうかをBooleanで返します。PhotoGalleryItemが持っているのは画像の情報です。画像が同じかどうかはURIを比べればわかりますので(というかPhotoGalleryItemはURIしか保持していないので)、URIが一致していればtrueを、そうでなければfalseを返します。

areContentsTheSame()は、areItemsTheSame()trueを返した場合に、追加で呼ばれ、アイテムの内容が完全に同じかどうかをBooleanで返します。areItemsTheSame()との違いが分かりづらいですが、まずはareItemsTheSame()でふるいにかけて、areContentsTheSame()で最終的に判断するイメージです。RecyclerViewで利用する場合、表示するすべての情報が同一であれば、areContentsTheSame()trueを返すようにします。今回は==で判定しています。PhotoGalleryItemdata classなので、==演算子は、すべてのプロパティが同一であることを意味します。これで、将来的にPhotoGalleryItemにプロパティが追加されても大丈夫です。

写真データのリストを作成する

次に、MediaStore APIを利用してデバイスの写真を検索し、PhotoGalleryItemオブジェクトのリストを作成する処理を、PhotoGalleryViewModelクラスに実装します。MediaStore APIを使う際にContextが必要になるため、PhotoGalleryViewModelクラスはAndroidViewModelクラスを継承します。

写真のリストphotoListList<PhotoGalleryItem>MutableLiveDataにしています。これにより、非同期の写真の読み込みが完了したタイミングでRecyclerViewに反映させることができます。

loadPhotoListでは、コルーチンを使ってIOスレッドでMediaStoreにアクセスしています。MediaStore APIの実装で前回と異なるのは、以下の点です。

  • projectionにIDと撮影日時を指定し、必要最小限の列のみを読み込むようにしている。
  • データベースへの追加日時順(DATE_ADDED)にソートしている。
  • AndroidViewModelgetApplication経由でcontentResolverを取得している。

ソート順にDATE_ADDEDを使う理由は、こちらの記事でも説明しています。他にDATE_TAKENやDATE_MODIFIEDなど似たようなColumnがありますが、DATE_ADDEDが一番使い勝手が良いと思います。

写真のリストを作成できたら、コルーチンの最後でpostValueを呼び出し、メインスレッドでphotoListにリストをセットしています。

getPhotoItemは指定したインデックスのアイテムを返す関数で、後ほどRecyclerViewのAdapterから呼び出します。

class PhotoGalleryViewModel(app: Application) : AndroidViewModel(app) {
    /* 写真のリスト */
    val photoList = MutableLiveData<List<PhotoGalleryItem>>()

    /* 写真のリストを読み込む */
    fun loadPhotoList() {
        /* IOスレッドでMediaStoreから写真を検索する */
        viewModelScope.launch(Dispatchers.IO) {
            val list = mutableListOf<PhotoGalleryItem>()

            /* 読み込む列の指定 */
            val projection = arrayOf(
                MediaStore.Images.Media._ID, /* ID : URI取得に必要 */
                MediaStore.Images.Media.DATE_ADDED  /* 撮影日時 */
            )
            val selection = null /* 行の絞り込みの指定。nullならすべての行を読み込む。*/
            val selectionArgs = null /* selectionの?を置き換える引数 */
            /* 並び順の指定 : 撮影日時の新しい順 */
            val sortOrder = "${MediaStore.Images.Media.DATE_ADDED} DESC"

            getApplication<Application>().contentResolver.query(
                MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
                projection, selection, selectionArgs, sortOrder
            )?.use { cursor -> /* cursorは、検索結果の各行の情報にアクセスするためのオブジェクト。*/
                /* 必要な情報が格納されている列番号を取得する。 */
                val idColumn = cursor.getColumnIndexOrThrow(MediaStore.Images.Media._ID)
                while (cursor.moveToNext()) { /* 順にカーソルを動かしながら、情報を取得していく。*/
                    val id = cursor.getLong(idColumn)
                    /* IDからURIを取得してリストに格納 */
                    val uri = ContentUris.withAppendedId(
                        MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id)
                    list.add(PhotoGalleryItem(uri))
                }
            }

            /* メインスレッドでphotoListにセットする */
            photoList.postValue(list)
        }
    }

    /* 指定したインデックスのPhotoGalleryItemを取得 */
    fun getPhotoItem(index: Int) = photoList.value?.getOrNull(index)
}

ViewModelの依存関係を挿入する

FragmentやActivityにViewModelへの依存関係を挿入するには、Koinを使うのが簡単でお気に入りです。

はじめに、ViewModelの依存関係を定義したmoduleを定義しておき、Applicationを継承したMainApplicationonCreateでKoinを開始します。

@JvmField
val appModule = module {
    viewModel {
        PhotoGalleryViewModel(androidApplication())
    }
}

class MainApplication: Application() {
    override fun onCreate() {
        super.onCreate()

        startKoin {
            androidContext(this@MainApplication)
            modules(appModule)
        }
    }
}

AndroidManifestでapplicationMainApplicationを指定してあげれば、アプリ起動時にKoinを開始することができます。

<application
    android:name=".MainApplication"
    ... >
</application>

これで、ActivityやFragmentにPhotoGalleryViewModelを挿入することができます。PhotoGalleryActivityPhotoGalleryFragmentで同じインスタンスを共有するため、PhotoGalleryFragment側ではby sharedViewModel()を使います。

class PhotoGalleryActivity : AppCompatActivity() {

    private val viewModel: PhotoGalleryViewModel by viewModel()

}
class PhotoGalleryFragment : Fragment() {

    private val viewModel: PhotoGalleryViewModel by sharedViewModel()

}

権限を取得する

フォトギャラリーの実装を進める前に、ここで権限取得の処理を実装しておきます。面倒ですが、これをやらないと動きません。。。

まず、ViewModelに権限が承認されたこと、拒否されたことを保持するLiveDataを作成しておきます。このLiveDataは後ほどViewにBindingし、フォトギャラリーの表示・非表示の切り替えや、メッセージの表示に使います。

    /* Permission承認されたかどうか */
    val isPermissionGranted = MutableLiveData<Boolean>().apply { value = false }
    /* Permission拒否されたかどうか */
    val isPermissionDenied = MutableLiveData<Boolean>().apply { value = false }

Fragmentには、権限要求のためのActivityResultLauncherを作成します。権限が承認・拒否されたらViewModelに通知します。また、権限が拒否された場合にはshouldShowRequestPermissionRationale()で追加の説明が必要かどうかを確認し、必要であればダイアログに説明を表示します。

companion object {
    private const val REQ_PERMISSION = Manifest.permission.READ_EXTERNAL_STORAGE
}

/* Permissionの結果を受け取る */
private val permissionRequest = registerForActivityResult(
        ActivityResultContracts.RequestPermission()
) { granted ->
    if (granted) {
        /* 承認された。*/
        viewModel.isPermissionGranted.value = true
    } else {
        /* 拒否された */
        if (shouldShowRequestPermissionRationale(REQ_PERMISSION)) {
            /* Permissionの必要性を説明するダイアログを表示する */
            showRationaleDialog()
        } else {
            /* 拒否(ファイナルアンサー)*/
            viewModel.isPermissionDenied.value = true
        }
    }
}

/* Permissionの必要性を説明するダイアログを表示する */
private fun showRationaleDialog() {
    RationaleDialog().show(childFragmentManager, viewLifecycleOwner) {
        if (it == RationaleDialog.RESULT_OK) {
            /* Permissionを要求 */
            permissionRequest.launch(REQ_PERMISSION)
        } else {
            /* あきらめる */
            viewModel.isPermissionDenied.value = true
        }
    }
}

class RationaleDialog: DialogFragment() {
    override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
        return activity?.let {
            AlertDialog.Builder(it)
                    .setMessage("デバイス内の写真を表示するには、アクセスを許可してください。")
                    .setPositiveButton("OK") { _, _ ->
                        setFragmentResult(REQUEST_KEY, bundleOf(Pair(RESULT_KEY, RESULT_OK)))
                    }
                    .setNegativeButton("Cancel") { _, _ ->
                        setFragmentResult(REQUEST_KEY, bundleOf(Pair(RESULT_KEY, RESULT_CANCEL)))
                    }
                    .create()
        } ?: throw IllegalStateException("Activity cannot be null")
    }

    fun show(manager: FragmentManager, lifecycleOwner: LifecycleOwner, callback: (Int)->Unit) {
        val listener = FragmentResultListener { _, result ->
            val resultValue = result.getInt(RESULT_KEY)
            callback(resultValue)
        }
        manager.setFragmentResultListener(REQUEST_KEY, lifecycleOwner, listener)
        show(manager, TAG)
    }

    companion object {
        private const val TAG = "TAG_RATIONALE_DIALOG"
        private const val REQUEST_KEY = "RATIONALE_DIALOG_REQUEST_KEY"
        private const val RESULT_KEY = "RATIONALE_DIALOG_RESULT_KEY"
        const val RESULT_OK = 1
        const val RESULT_CANCEL = -1
    }
}

実際の権限の要求は、onCreate()内で実行します。checkSelfPermission()で、権限が取得済みかどうかを確認し、未取得の場合は、先ほど用意したActivityResultLanucherlaunch()に必要な権限を指定して要求します。

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)

    context?.let {
        /* 必要なPermissionが与えられているかどうかを確認 */
        val result = PermissionChecker.checkSelfPermission(it, REQ_PERMISSION)
        if (result == PermissionChecker.PERMISSION_GRANTED) {
            /* 承認済み */
            viewModel.isPermissionGranted.value = true
        } else {
            /* 要求する */
            permissionRequest.launch(REQ_PERMISSION)
        }
    }
}

companion object {
    private const val REQ_PERMISSION = Manifest.permission.READ_EXTERNAL_STORAGE
}

権限取得の処理は以上です。あとは、onResumeが呼ばれたタイミングで権限取得済みであれば、デバイスから写真を読み込みます。

override fun onResume() {
    super.onResume()
    /* Permission承認済みなら、デバイスの写真にアクセスする */
    if (viewModel.isPermissionGranted.value == true){
        viewModel.loadPhotoList()
    }
}

Fragmentのレイアウトを作成する

PhotoGalleryFragmentのレイアウトXMLは以下のようになっています。PhotoGalleryViewModelをデータバインディングし、権限が拒否された場合はメッセージのTextViewを、承認された場合はフォトギャラリーのRecyclerViewを表示する構成になっています。

<?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>
        <import type="android.view.View" />
        <variable
            name="viewModel"
            type="net.engawapg.app.photogallery.gallery.PhotoGalleryViewModel" />
    </data>

    <androidx.constraintlayout.widget.ConstraintLayout
        ... >

        <TextView
            android:visibility="@{viewModel.isPermissionDenied ? View.VISIBLE : View.INVISIBLE}"
            android:id="@+id/permissionExplanation"
            ...
            android:text="デバイス内の写真を表示するには、アプリの設定画面でアクセスを許可してください。"
            ... />

        <androidx.recyclerview.widget.RecyclerView
            android:id="@+id/recyclerView"
            ...
            android:visibility="@{viewModel.isPermissionGranted ? View.VISIBLE : View.INVISIBLE}"
            ... />

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

Fragment側では、onCreateViewでデータバインディングの初期化を行います。Fragment内のViewにbindingオブジェクト経由でアクセスするため、bindingオブジェクトはクラス変数として保持しておきます。(※記事公開当初はandroid-kotlin-extensionsを使ってrecyclerViewにアクセスしていましたが、DataBindingオブジェクト経由でアクセスするように変更しました。移行に関してはこちらの記事もご参考ください。)

private var _binding: FragmentPhotoGalleryBinding? = null
private val binding get() = binding!!

override fun onCreateView(
    inflater: LayoutInflater, container: ViewGroup?,
    savedInstanceState: Bundle?
): View {
    _binding = DataBindingUtil.inflate(
        inflater, R.layout.fragment_photo_gallery, container, false)
    binding.viewModel = viewModel
    binding.lifecycleOwner = viewLifecycleOwner
    return binding.root
}

override fun onDestroyView() {
    super.onDestroyView()
    _binding = null
}

RecyclerViewに画像を非同期で表示する

さて、ようやくRecyclerViewの設定までたどり着きました。FragmentのonViewCreatedで、Adapterを初期化し、recyclerViewに設定します。データソースであるPhotoGalleryViewModel#photoListは非同期で更新されますので、observeで変更を監視し、変更があったらAdapterにsubmitListします。

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

    /* RecyclerViewの設定 */
    val imageAdapter = ImageAdapter()
    /* リストは後から更新する */
    viewModel.photoList.observe(viewLifecycleOwner, Observer {
        imageAdapter.submitList(it)
    })
    binding.recyclerView.apply {
        setHasFixedSize(true)
        layoutManager = GridLayoutManager(this@PhotoGalleryFragment.context, SPAN_COUNT)
        adapter = imageAdapter
    }
}

RecyclerViewの一つ一つのViewのレイアウトXMLは以下のようになっています。PhotoGalleryViewModelPhotoGalleryItemをバインディングし、PhotoGalleryViewModelのクリックイベントにPhotoGalleryItemを渡しています。ViewModel側のイベント処理は後で説明します。

layout_constraintDimensionRatio="1"を指定して正方形にしているのもポイントです。

<?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="net.engawapg.app.photogallery.gallery.PhotoGalleryViewModel" />
        <variable
            name="item"
            type="net.engawapg.app.photogallery.gallery.PhotoGalleryItem" />
    </data>

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

        <ImageView
            android:id="@+id/imageView"
            android:layout_width="0dp"
            android:layout_height="0dp"
            android:contentDescription="Image in the device"
            app:layout_constraintDimensionRatio="1"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent"
            android:onClick="@{() -> viewModel.onClick(item)}"
            tools:ignore="HardcodedText" />

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

AdapterはPhotoGalleryFragmentinner classで定義しています。こうすることにより、PhotoGalleryFragmentが保持しているviewModelへのアクセスが楽になります。

画像の読み込みとImageViewへのセットには、Picassoライブラリを使用しています。Picassoは画像の読み込みやリサイズなどの変換を非同期に実行してくれるとても便利なライブラリです。公式説明に

Handling ImageView recycling and download cancelation in an adapter.

Complex image transformations with minimal memory use.

Automatic memory and disk caching.

https://square.github.io/picasso/#introduction

と書かれている通り、ImageViewが再利用される際のダウンロードのキャンセルや、データのキャッシュなども自動でやってくれます。これらをすべて自前で実装しようとすると、Androidのバージョンによっても実装が変わってきますし、かなりの手間ですので、ありがたくライブラリを使わせてもらいましょう。また、

Resources, assets, files, content providers are all supported as image sources.

https://square.github.io/picasso/#features

とあるとおり、MediaStore APIでContentResolverから取得したURIからも画像をloadできます。今回は、PhotoGalleryViewModel#getPhotoItemで取得したPhotoGalleryItemが保持しているURIをloadし、fitでImageViewのサイズに合わせ、centerCropで中央に表示し、intoでImageViewにセットしています。RecyclerViewのAdapter側では特にスレッドやコルーチンのことを考えなくても、非同期に画像の読み込みができるのでとても便利です。

inner class ImageAdapter
    : ListAdapter<PhotoGalleryItem, ImageViewHolder>(PhotoGalleryItem.DIFF_UTIL) {

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ImageViewHolder {
        val layoutInflater = LayoutInflater.from(parent.context)
        val binding = DataBindingUtil.inflate<ViewPhotoGalleryImageBinding>(
                layoutInflater, R.layout.view_photo_gallery_image, parent, false)
        return ImageViewHolder(binding)
    }

    override fun onBindViewHolder(holder: ImageViewHolder, position: Int) {
        val item = viewModel.getPhotoItem(position)

        holder.binding.viewModel = viewModel
        holder.binding.item = item
        holder.binding.executePendingBindings()

        item?.let {
            /* Picassoライブラリを使って非同期に画像を読み込む */
            Picasso.get().load(it.uri)
                .fit()
                .centerCrop()
                .into(holder.binding.imageView)
        }
    }
}

inner class ImageViewHolder(val binding: ViewPhotoGalleryImageBinding)
    : RecyclerView.ViewHolder(binding.root)

onBindViewHolderでデータバインディング変数を代入した後、executePendingBindingsを呼ぶのも重要です。データバインディング変数は、ソースコード側で代入してすぐにView側に反映されるとは限りません。executePendingBindingsを呼ぶことにより、即座にViewに反映させることができます。もしexecutePendingBindingsを呼ばないと、RecyclerViewをスクロールしてViewが再利用されるときに、正しいデータが表示されない可能性があります。

タップした画像のURIを取得する

ここまでで画像の表示ができたので、次はタップした画像のURIを取得する処理を実装します。ImageView側は、PhotoGalleryViewModel#onClickを呼び出すようにバインディングを記述済みです。android:onClick="@{() -> viewModel.onClick(item)}"の部分です。

PhotoGalleryViewModel側の実装は下記のとおりです。onSelectにURIのEventをセットしています。こうすることで、PhotoGalleryViewModelを参照するUIのどの部分からでもURIを取得することができます。今回はPhotoGalleryActivityでURIを参照しますが、同じActivityの別のFragmentからURIを参照するというようなことも可能になります。

val onSelect = MutableLiveData<Event<Uri>>()

fun onClick(item: PhotoGalleryItem) {
    /* URIをイベントとして渡す */
    onSelect.value = Event(item.uri)
}

Eventについては「ViewModelからFragmentへ画面遷移イベントを送りたい」をご覧ください。Eventを使うと、ViewModelでMutableLiveDataに値をセットしたときに、そのMutableLiveDataobserveしている側の処理を1回だけ呼び出すことができます。

ActivityResultContractを使って呼び出し元にURIを返す

PhotoGalleryActivityでは、PhotoGalleryViewModel#onSelectイベントをobserveし、URIが変化したらActivityにsetResultして、finishでActivityを終了します。これにより、呼び出し元のActivityにResultとしてURIが渡されることになります。

class PhotoGalleryActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        ...
        /* ViewModelでURIが選択されたイベントを受信し、Activityの結果としてセットする */
        viewModel.onSelect.observe(this, EventObserver {
            setResult(RESULT_OK, Intent().putExtra(INTENT_URI, it.toString()))
            finish()
        })
    }

    companion object {
        const val INTENT_URI = "Uri"
    }
}

しかしこのままでは、呼び出し元ActivityがIntentのExtraのKey(ここではINTENT_URI)を知っている必要がありますし、取得されるデータが、URIを変換した文字列である、ということも知っている必要があります。そこで、これらをカプセル化するために、ActivityResultContractのサブクラスを定義します。ここで定義したResultContractは、PhotoGalleryActivityと、呼び出し元のMainActivityを関連付けるインターフェースの役割を担います。

class PhotoGalleryActivity : AppCompatActivity() {
    /* ActivityResultContractのカスタマイズ。このActivityのResultとしてURIを返す */
    class ResultContract: ActivityResultContract<Unit, Uri?>() {
        override fun createIntent(context: Context, input: Unit?) =
            Intent(context, PhotoGalleryActivity::class.java)

        override fun parseResult(resultCode: Int, intent: Intent?): Uri? {
            return if (resultCode == RESULT_OK) {
                intent?.getStringExtra(INTENT_URI)?.let {
                    Uri.parse(it)
                }
            } else {
                null
            }
        }
    }
}

Activityを呼び出し、結果を受け取る

最後に、MainActivityからPhotoGalleryActivityを呼び出し、結果を受け取ります。上で定義したPhotoGalleryActivity.ResultContractによって、Intentはカプセル化されるので、MainActivity側の実装は非常にシンプルになります。

class MainActivity : AppCompatActivity() {

    private lateinit var binding: ActivityMainBinding

    /* PhotoGalleryActivityの結果をURIで受け取る */
    private val launcher = registerForActivityResult(PhotoGalleryActivity.ResultContract()) {
        binding.imageView.setImageURI(it)
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = DataBindingUtil.setContentView(this, R.layout.activity_main)

        binding.button.setOnClickListener {
            launcher.launch()
        }
    }
}

まず、registerForActivityResultの引数にResultContractを指定し、ランチャーを作成します。launchを呼び出すと、内部ではResultContract#createIntentで作成したIntentが使われ、PhotoGalleryActivityが起動します。PhotoGalleryActivityから結果を受け取る際は、ResultContract#parseResultでIntentから取り出したURIが、registerForActivityResultのコールバック関数に引数として渡されます。MainActivity側では、受け取ったURIを処理するだけです。ここではImageViewにセットし、選択した画像を表示しています。

まとめ

長くなりましたが、実用に耐えるフォトギャラリーを実装することができました。他のアプリに移植する場合には、PhotoGalleryActivityごと移植することも可能ですし、PhotoGalleryFragmentだけでも可能です。Fragmentだけ移植する場合は、PhotoGalleryViewModel経由で移植先のActivityに選択した画像のURIを受け渡すことができます。