フォトギャラリー最小実装でMediaStoreを理解する

前回紹介したAndroid標準ギャラリーを使って画像をピックアップする方法は、実装が簡単な反面、見た目をカスタマイズできないなどのデメリットもありました。そこで今回は、MediaStore APIを使ってAndroidの画像データベースから画像を取得する方法を説明します。

ただ、標準ギャラリーのように高機能にすると、ソースコードの量もそれなりに多くなってしまい、MediaStore APIの本質を理解するのが難しくなってしまいます。そこで今回はまず、最小限のソースコードでとにかく写真を一覧表示することだけを考えます。この記事を読んで、MediaStoreについて理解できたら、自分に必要な機能を追加していってください。

メディアファイルへのアクセス

Androidでは、画像や動画などのメディアファイルは、「共有ストレージ」と呼ばれる場所に保存されます。共有ストレージに保存されているデータは、アプリ間で共有できます。例えば、カメラアプリで撮影した画像をSNSアプリで開くことができる、といった具合です。

共有ストレージのメディアファイルは、MediaStore APIがデータベースとして管理しています。メディアの種類ごとにインデックスが振られ、MediaStore.ImagesMediaStore.VideoMediaStore.Audioクラスが、各データベースにアクセスするためのURIを提供しています。アプリは、ContentResolverを使ってこのデータベースにアクセスします。

メディアファイルにはMediaStoreとContentResolver経由でアクセスする。
メディアファイルにはMediaStoreとContentResolver経由でアクセスする。

実際に写真データを取得するには、ContentResolver#query()関数を使ってデータベースを検索します。検索の仕組みはSQLに似た形で条件を指定します。が、今回は最小実装ですので条件は指定しません。条件を指定しない場合、すべての画像のすべての属性を取得します。非常にパフォーマンスが低下しますので、実際にアプリに実装する際は、必要な情報のみ取得するようにしてください。

実装

それではソースコードを見ていきます。

権限の取得

本題のMediaStoreへのアクセスの前に、必要な権限を取得する必要があります。Androidの面倒な部分ですが、セキュリティ上仕方ないことなので、あきらめてチャチャっと実装してしまいましょう。

MediaStoreのデータを参照するには、READ_EXTERNAL_STORAGE権限が必要です。他のアプリが作成(撮影)したファイルにアクセスするからですね。

前回のAndroid標準ギャラリーの場合、ユーザー自身がファイルを選択するので特別な権限は必要ありませんでした。対してMediaStore APIを使うと、アプリは共有ストレージ上のあらゆるメディアファイルにアクセスできてしまいます。悪意を持ったアプリの場合、ユーザーが知らないうちに端末内の写真データを取得したりすることもできてしまうわけです。そのため、実行時にユーザーの承認を必要とする仕組みになっています。

ちなみに、データを保存したり書き換えたりする場合は、WRITE_EXTERNAL_STORAGE権限も必要になります(Android 9以下の場合)。今回はデータを参照するだけですので不要です。

AndroidManifest.xmlREAD_EXTERNAL_STORAGEを宣言します。

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

    <application ... >
        ...
    </application>
</manifest>

実行時の権限リクエストは、MainActivityonResume()で実施します。

override fun onResume() {
    super.onResume()

    /*
    READ_EXTERNAL_STORAGEパーミッション取得済みかどうかの確認。
    READ_EXTERNAL_STORAGEは実行時に権限を要求する必要がある。
    簡単のため、パーミッション関係の実装は正常系のみとしている。
    */
    val result = PermissionChecker.checkSelfPermission(this,
        Manifest.permission.READ_EXTERNAL_STORAGE)

    /* パーミッション未取得なら、要求する。 */
    if (result != PermissionChecker.PERMISSION_GRANTED) {
        requestPermissions(arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE), REQUEST_CODE)
        return
    }

    /* パーミッション取得済みなら、画像を読み込む。*/
    loadImages()
}

PermissionChecker#checkSelfPermission()で権限を取得済みかどうかを確認し、未取得の場合は権限をリクエストします。requestPermissions()を呼ぶとActivityはPause状態になり、ダイアログが表示されます。

ユーザーに権限を要求するダイアログ
ユーザーに権限を要求するダイアログ

「許可」をタップするとMainActivityに制御が戻り、再びonResume()が呼ばれます。今度は権限は取得済みですので、loadImages()が呼ばれ、処理が続行されます。

しつこいようですが今回は最小実装ですので、権限を許可しなかった場合の処理は入っていません。この実装では、承認されるまで永遠にダイアログが出続けます。実際のアプリでは好ましくありません。

MediaStoreへアクセス

権限が取得できたら、MediaStoreへアクセスすることができます。loadImages()は以下のような実装で、画像のURIを取得して、Listに格納していきます。

private fun loadImages() {
    imageUris.clear()

    /* 検索条件の設定。このサンプルはとにかく簡単にするため、すべてnull */
    val projection = null /* 読み込む列の指定。nullならすべての列を読み込む。*/
    val selection = null /* 行の絞り込みの指定。nullならすべての行を読み込む。*/
    val selectionArgs = null /* selectionの?を置き換える引数 */
    val sortOrder = null /* 並び順。nullなら指定なし。*/

    /*
    このサンプルではMediaStore以外のソースコードを極力少なくするため、メインスレッドで実行している。
    そのため、スクロールが頻繁に固まる。実際のアプリではバックグラウンドスレッドで実行すること。
    */
    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)
            imageUris.add(uri)
        }
    }

    /* 表示更新 */
    recyclerView.adapter?.notifyDataSetChanged()
}

ContentResolver#query()の第一引数は、コンテンツを取得するベースのURIです。MediaStore.Images.Media.EXTERNAL_CONTENT_URIを指定することにより、DCIMやPicturesといったディレクトリの画像にアクセスできます。第2~第5引数は、SQLと同様の検索条件の指定です。とにかく結果を見たいだけであれば、すべてnullで構いませんが、実際のアプリでは、必要最小限の情報に絞ってパフォーマンスを改善する必要があります。また、本来はquery()はバックグラウンドスレッドで実行すべきですが、今回は簡単のためにUIスレッドで実行しています。

ContentResolver#query()の戻り値は、Cursorオブジェクトです。Cursorは、データベースの検索結果にアクセスするためのインターフェースです。これを使って、実際のデータを取得します。上の例では画像のIDを取得し、そこから画像のURIに変換して、リストに格納しています。

RecyclerViewに表示

リストに格納したURIを使って、RecyclerViewに表示します。AdapterViewHolderは以下のような感じで、こちらも最小限の実装にしています。

inner class ImageAdapter: RecyclerView.Adapter<ImageViewHolder>() {
    override fun getItemCount() = imageUris.size

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ImageViewHolder {
        val layoutInflater = LayoutInflater.from(parent.context)
        val view = layoutInflater.inflate(R.layout.view_image_item, parent, false)
        return ImageViewHolder(view)
    }

    override fun onBindViewHolder(holder: ImageViewHolder, position: Int) {
        holder.itemView.imageView.setImageURI(imageUris[position])
    }
}

inner class ImageViewHolder(v: View): RecyclerView.ViewHolder(v)

最終的な表示はこんな感じ。ちゃんと端末内の写真が表示されました。

画像を表示できた
画像を表示できた

まとめ

今回は、シンプルな画像ギャラリーを最低限のコードで実装しました。メインスレッドで動作させているので、RecyclerViewがまともにスクロールできないなど、このままでは実用に耐えられるものではないですが、MediaStore APIの使い方を覚える第一歩としては良い練習になると思います。

ソースコードはGitHubにもUPしてますので、参考にしてください。