こんにちは。サイバーエージェント メディア事業本部 Ameba事業本部でAndroidアプリエンジニアをしている和田( @e10dokup )です。

10/19-21で開催されたDroidKaigi 2021において、「Now and Future of Media Access / メディアアクセス古今東西」という、Scoped Storageを始めとする昨今のAndroidを取り巻くメディアアクセス手法について解説する内容の登壇をさせていただきました。

内容としては、以下のような構成です。

- 現行のAndroidのローカルストレージとこれまでのアップデートについて
- ContentResolverによるメディアアクセスの手法についての解説
- google/modernstorage によってメディアアクセスがどう変わるのか

また、発表動画、及びスライドもすでに公開されておりますので、合わせてご覧ください。

本記事では、このセッションの書き起こし、及びDroidKaigi 2021で気になったセッションの紹介をしていこうと思います。

本セッションのトピックとゴール

本セッションで扱うトピック

まず、本セッションで扱うのはAndroidアプリにおけるメディアを扱うユースケースのうち、「外部アプリとのメディア共有」にコンテキストを絞っています。具体的には「カメラ等、外部アプリによって生成されたデータを取得すること」であったり、「編集等で生成したデータの外部アプリへの共有」のような処理です。

本セッションのゴール

そこで、本セッションのゴールは以下の2点で定義しました。

  • ContentResolverを用いたメディアアクセスについて理解すること
  • 後述するgoogle/modernstorageによるメディアアクセス手法の変化を垣間見ること

これらによって、今開発されているアプリ、開発することになるアプリにメディアアクセスを伴う機能があるとき、その実装をより最新に即した状態でスマートに行うことができるようになることを目指します。

Androidで扱われてきたメディアアクセスの手法

先述の通り、トピックを「外部アプリとのメディア共有」に絞ったので、今回扱うのはあくまで「メディアファイルのURIを取得し、それを外部アプリとやり取りが可能にできる状態にすること」になります。

Intent発行によるギャラリーアプリの呼び出し

まず、AndroidではIntentを用いることで外部アプリを呼び出し、その結果をonActivityResultで受け取ることができます。つまり、Android初期から実装されている ACTION_GET_CONTENT やAndroid 4.4以降から追加された ACTION_OPEN_DOCUMENT といったIntentを利用することでギャラリーアプリやStorage Access FrameworkによるシステムUI経由でファイルのURIを取得することができました。

ContentResolverとMediaStore APIを利用する

そしてもう一方で、今回メインで取り扱うContentResolver、MediaStore APIを利用してアプリ内に独自実装する手法もAndroid初期から存在します。Androidはシステムによるスキャンで画像は MediaStore.Images 、動画は MediaStore.Videos 、…といった具合にコレクションされているので、これにContentResolverを用いてアクセスする、というわけです。

ContentResolverとMediaStore

実際にMediaStoreのデータが保管されるのはContentProviderという、アプリ間をまたがったデータ共有を行うフレームワークです。ここにSQLのテーブルのような形でメディアファイルの情報がコレクションされていきます。そこに対してContentResolverを用いてアクセスし、クエリや保存、更新のような操作を実現しています。

Androidにおけるメディアアクセスで備えておくべき知識

Androidのストレージの区別について

Content URI/File URIの扱いやAndroid 6で実装されたRuntime Permissions等、様々なバージョンアップでAndroidでのメディアアクセス手法は変化してきましたが、特に大きなトピックはAndroid 10以降で実装された、Scoped Storageでしょう。これによって、ファイルアクセスに対して要求する権限がファイルの置き場によるものからアプリ固有のファイル/メディア/ドキュメントやファイルといったファイルの利用目的によるものに変更されました。

Android 10まででは requestRegacyExternalStorage をAndroidManifest.xml上でtrueにすることでオプトアウトすることが可能でしたが、Android 11のタイミングでこれが無視され強制的にScoped Storageが適用されるので、今アップデートを控えていたり、新規にメディアアクセスを実装する際には注意が必要です。

ContentResolverとMediaStore APIによるメディアアクセスの実装

ここまで紹介したところで本題として、ContentResolverとMediaStore APIを用いたメディアアクセスの実装例を見ていきます。本セッションでは次に示す2点について解説したので、それぞれについて見ていきましょう。

  • 端末内の画像ロード
  • 編集後等の画像の新規保存

端末内の画像ロード

流れとしては

  1. ContentResolverからContentProviderへのクエリを発行する
  2. ContentResolverのクエリ結果からデータ(ContentスキーマURI)を取り出す
  3. 実際に取得したデータを表示させる

のような流れです。まずはContentResolverからContentProviderへのクエリを発行する箇所を見ていきましょう。前提として、ContentResolverが発行するクエリはSQL文に相当して考えることができます。それを踏まえて、クエリの発行に必要なProjection、Selection、SortOrderの設定手法を見ていきます。

Projectionを指定する

まず、ProjectionはSQL文におけるSELECTの直後に続くカラム指定に相当します。なので、ここで指定したカラムの値がクエリ結果に乗ることになります。

実際にProjectionが指定する内容は MediaStore.(Images/Videos/...).Media 下に様々なインターフェースの実装として用意されており、ここから参照することになります。

Selection + Args、SortOrderを指定する

続いてSelectionはArgと組み合わせることでSQL文におけるWHERE句に相当する条件指定が可能です。一方で、SortOrderはSQL文に於けるORDER BY句に相当するクエリ結果の順序指定が可能です。

こちらも、Projectionに渡したものと同様のカラムを参照し、Selectionであれば不等号などを用いた判定式であったり、SortOrderであればASC/DESCのようなその値に関する条件値を指定することになります。

ContentResolver#queryメソッドでクエリを発行する

ここまで揃ったタイミングで、ようやくクエリを発行することが可能になります。実際にはContentResolver#queryメソッドでクエリを発行し、このメソッドの引数に先程用意したProjection/Selection/SortOrderを与えていくのですが、第一引数にCollectionというワードが出てきました。CollectionはSQL文に於けるFROM句に相当するもので、クエリ対象となるコレクションのURIを与えます。

クエリ対象となるコレクションのURI生成

今は画像をロードしているので、MediaStore.ImagesのURIを選ぶ必要があります。Android 10未満では MediaStore.Images.Media.EXTERNAL_CONTENT_URL を参照してURIを直接取得すれば良かったのですが、Android 10以降では MediaStore.Images.Media.getContentUri メソッドに MediaStore.VOLUME_EXTERNAL という値を渡すことが推奨されています。

クエリ結果からデータを取り出す

ここまで行えば実際のクエリ結果が得られるので、結果が入っているCursorインスタンスを操作して必要なデータの抽出を行うことになります。Cursorインスタンスを操作する中で、カラムインデックスは共通で扱うことができるため、操作中に何度も呼び出すことを避ける目的でキャッシュをまずは行いましょう。

クエリ結果からデータを取り出す 二枚目 クエリ結果からデータを取り出す 3枚目

続いて、キャッシュしたカラムインデックスを用いて必要なデータを抽出していきます。
基本はカラムインデックスを与えてgetString/getIntのようなメソッドを実行することでファイル名、ファイルサイズといったデータを抽出しますが、メディアそのものを指すContentスキーマURIは ContentUris.withApendedId メソッドを使って生成しましょう。

ここまでやれば、ContentスキーマURIをUIまで持ってくることができるので、実際に画像を表示させることができるようになります。サムネイルとしてのBitmapの取得の方法であったり、多すぎる画像のロードのためのページングロードについてもスライドのNOTEで解説しているので是非スライドや発表動画も参照してみてください。

編集後等の画像の新規保存

メディアは読み込むだけではなく、新規に保存することももちろんユースケースに入ります。ここでは編集後の画像を新しい画像として保存し、ContentResolverがクエリできる形にする手法を紹介しました。具体的な流れとしては、

  1. ファイル名等の保存に必要な要素を用意する
  2. ファイル保存先のURIを用意する
  3. 用意したURIにファイルの中身を書き込む

という流れになります。1に上げた保存に必要な要素の用意については、ファイル名を生成したり、既存のファイルと同じファイルのMimeTypeを用意したりすることなので省略し、2に上げたファイル保存先にURIの生成について解説します。発表内容ではアプリ固有のファイルとしての保存についても紹介しましたが、今回はMediaStoreに登録したURIを取得する手法に絞って解説します。

MediaStoreに登録する

MediaStoreに登録したURIを発行することで、「スキャンされなくともMediaStoreにそのメディアが存在する」状況を作ることができます。実際にContentResolver#insertメソッドでそのURIを生成することができるのですが、その前段階として、ContentValuesを用意し、そこに先程用意したファイル名等の要素をputしていく操作が必要です。

IS_PENDINGを利用した排他的アクセスの実現

また、Android 10以降、ContentValuesに追加できるカラムとして IS_PENDING が追加されました。これを1にすることによって「ファイル操作中は他のアプリに対して表示されない」といったことを行うことが可能になります。操作後は0に戻すことで、他のアプリからのアクセスができる状態にしてあげましょう。

ターゲットとなるコレクションのURI生成 VOLUME_EXTERNALとVOLUME_EXTERNAL_PRIMARY

また、URIの生成にも対象となるMediaStoreのコレクションのURIを指定する必要があります。ほとんどクエリと同様の指定なのですが、クエリと違って、今回はAndroid 10以降で MediaStore.VOLUME_EXTERNAL_PRIMARY を MediaStore.Images.Media.getContentUri メソッドに渡しています。 MediaStore.VOLUME_EXTERNAL 、 MediaStore.VOLUME_EXTERNAL_PRIMARY には、それぞれ読み取り専用、読み書き可能なURIとしての使い分けがあるので、一緒くたにしないようにしましょう。

ファイル保存の実装手法

ここまでで保存先のURIが生成できたので、後はOutputStreamなどを使ってファイルの中身を書き込めば、保存は完了です。この時点でMediaStoreに登録されたURIに保存しているので、画像のクエリを再実行すると保存した画像がすぐに表示される、といった状態が実現できるようになります。

google/modernstorage を覗き見る

最後に google/modernstorage を未来としてみていきましょう。これはAndroidのDevRelチームがストレージチームと協力して開発しているライブラリ群です。原状だとMediaStore/Storage Access Frameworkそれぞれに向けて実装が進んでいますが、今回は実装例でも取り上げたMediaStore用の実装であるmodernstorage-mediastoreについて見ていきます。

modernstorage-mediastore

modernstorage-mediastoreはMediaStoreの扱いを抽象化したレイヤーを提供するライブラリで、使用者はライブラリ中にある MediaStoreRepository を操作することでこれまで説明したScoped Storageに伴うバージョンごとの実装差異といった複雑な部分を考慮せずメディアアクセスを実装することができるようになります。

MediaStoreアクセスに伴うパーミッションチェック

例えば、ModernStorageに伴うメディアアクセス周りのパーミッションチェックを行うヘルパーメソッドであったり、

MediaStoreへのメディアの追加、変更時のスキャン

今回実装例で説明したようなメディアストアに登録するURIを発行しつつ新規ファイルとして保存するような処理を addMediaFromStream で1メソッドで実装することができるようになるなど、複数のメディアアクセスにまつわる処理が実装されています。

終わりに

本セッションではAndroidのバージョン遷移に伴うメディアアクセスの変更を見ていきながら必要な知識を抑え、実際のContentResolverとMediaStore APIを用いたメディアアクセスの実装例を解説し、メディアアクセスの未来として開発が進められている google/modernstorage を紹介しました。AndroidにおけるメディアアクセスはOSバージョンアップに伴う変更が大きく行われることが多く、いざアップデートするタイミングで「ここの改修が必要なのでは」と判明することがそこそこ発生するかと思います。しかし、SNSのようなユーザの画像・動画を扱うことがコア機能に扱われるようなサービスではユーザ体験に大きく関わるため、細かくキャッチアップを行い、今後も適宜必要な改修を施し、ユーザに適切なメディアアクセスを提供できるよう、頑張っていきましょう。

余談:DroidKaigi 2021に参加して気になったセッション

今回のDroidKaigi 2021ではスピーカーとして参加しましたが、聞いていて興味深かったセッションをいくつか紹介したいと思います。

持続的なサービス提供のための計測と分析

サービスの機能改善を行うときに必要な効果検証について解説されたセッションです。エンジニアの方面からも施策の改善提案だったり、自発的に施策をすることもあるので、何を以て仮設を立てるのか、どのような計画をたてるのか、これらを踏まえて何を目標とするのか、実際に結果をどう解釈して効果を検証するのか、その結果を受けてどう改善していくのかを事実ベースで実現していく重要性が実例を踏まえてよく感じられるセッションだったと思います。

2021年こそアクセシビリティと向き合おう

Androidアプリにおけるアクセシビリティの実装について解説されたセッションです。僕が開発を担当しているAmebaはブログを「読んでもらう・書いてもらう」サービスなので、アクセシビリティがとても大事な要素となっており、興味深く聞いていました。動画はDroidKaigi 2021中では語られていない部分も含む完全版となっていて、デザインレベルでの方針であったり、ContentDescriptionの定義であったりといった基礎的な部分からJetpack Compose環境下におけるアクセシビリティ実装まで解説されているので、DroidKaigi 2021本会中でお話を聞いたよ、という方にも是非もう一度おすすめしたいセッションだと思います。

【PR】カジュアル面談やってます

末筆になりますが、サイバーエージェントはDroidKaigi 2021に協賛し、ゴールドスポンサーをさせていただきました。スタッフの皆様、DroidKaigi 2021の運営お疲れさまでした!そしてありがとうございました!

また、私が所属しているAmebaを始め、サイバーエージェントには様々なサービスが存在しています。どのプロダクトも絶賛Androidエンジニア採用中でして、弊社社員とのカジュアル面談をやっております。下記ツイートのようなmiroをご用意しており、色々お話ができればと思いますので、是非下記ツイートのHRMOSリンクからご応募いただけますと幸いです!

2017年新卒入社のAndroidアプリエンジニアです。スマートフォンやカメラといったガジェットを買い漁っては積み上がった山を見てなんで買ったんだろうと定期的に反省しています。