炊きたてのご飯が食べたい

定時に帰れるっていいね。自宅勤務できるっていいね。子どもと炊きたてのご飯が食べられる。アクトインディでは積極的にエンジニアを募集中です。

Glide のメソッドをお借りして画像の向き(orientation)を取得する

アクトインディ Advent Calendar 2016 16日目の記事になります。子どもとおでかけ情報アプリ「いこーよ」は、マップ上で簡単におでかけ先を探せる検索アプリです。記事を見てくれたパパさん、ママさん、ぜひ一度使ってみてください^^

iko-yo.net

Kotlin 1.0.5
minSdkVersion 19
targetSdkVersion 25

本日の記事は Android アプリでギャラリーやカメラから取得した画像をサーバーにアップロードする際に必要となる、画像の向きの情報について書きたいと思います。Intent 経由で起動したカメラの Uri から bitmap を取得して Retrofit を使って画像のアップロードを行なったところ、デバイスを縦にして撮影した画像が横向きでアップロードされるといった問題に出くわしました。あー、画像の向きを考慮して bitmap を生成しないといけないのかぁ、と気づいて、まぁ Uri に画像の向きを取得するメソッドありそうだから探してみるかー、と簡単な気持ちで臨んだのが全ての誤りでした。最終的には Glide の public メソッドを利用させてもらうといった暴挙に出たのですが、それに至った経緯を記事に残しておこうと思います。

  1. ExifInterface(uri.path) で orientation の値が常に 0 が返却されてしまう
  2. contentResolver を利用して filePath を取得しようとすると READ_EXTERNAL_STORAGE パーミッションが必要となる
  3. contentResolver を利用して filePath を取得するが、ギャラリーとカメラで違う実装が必要になる
  4. contentResolver を利用して filePath を取得するが、ギャラリーのダウンロードフォルダから選択した画像はまた別の処理になる
  5. 独自に実装を諦めて Glide さんのお力を借りることを決意する

f:id:t-namikata:20161215231353p:plain

Glide のメソッドを借りるまでの経緯を紹介すると長くなってしまうので、最終的な画像の orientation を取得する実装を先に紹介しておきます。以下のように実装しました。

  • UriExtension.kt
fun Uri.getOrientationWithGradle(): Int {
    val byteArrayPool = ByteArrayPool.get()
    byteArrayPool.bytes
    val byteForStream = byteArrayPool.bytes
    val contentResolver = SampleApplication.instance.contentResolver
    val inputStream = contentResolver.openInputStream(this)
    val bufferedStream = RecyclableBufferedInputStream(inputStream, byteForStream)
    val exceptionStream = ExceptionCatchingInputStream.obtain(bufferedStream)
    val orientation = ImageHeaderParser(exceptionStream).orientation
    inputStream.close()
    return orientation
}
  • SampleApplication.kt
class SampleApplication : Application() {
    companion object {
        lateinit var instance: SampleApplication
            private set
    }

    init {
        instance = this
    }

    override fun onCreate() {
        super.onCreate()
    }
}

ギャラリーとカメラは以下のように Intent 経由で起動しています。

  • ギャラリーの起動
fun onGalleryClick() {
    val intent = Intent()
    intent.type = "image/*"
    intent.action = Intent.ACTION_OPEN_DOCUMENT
    intent.addCategory(Intent.CATEGORY_OPENABLE)
    this.startActivityForResult(intent, 0)
}
  • カメラの起動

AndroidManifest で <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/> が必要

fun onCameraClick() {
    this.cameraUri = CameraUtility.cameraUri(activity = this)
    val intent = Intent(MediaStore.ACTION_IMAGE_CAPTURE)
    intent.putExtra(MediaStore.EXTRA_OUTPUT, this.cameraUri)
    this.startActivityForResult(intent, 1)
}

class CameraUtility {
    companion object {
        fun cameraUri(activity: Context): Uri {
            val imageName = "${System.currentTimeMillis()}.jpg"
            val contentValues = ContentValues()
            contentValues.put(MediaStore.Images.Media.TITLE, imageName)
            contentValues.put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg")
            return activity.contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues)
        }
    }
}
  • ギャラリーとカメラの Uri の取得
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
    when (requestCode) {
        0 -> {
            data ?: return
            if (resultCode != Activity.RESULT_OK) return
            val uri = data.data
            val orientation = uri.getOrientationWithGradle() // 画像の向きによって Int が返却される
        }
        1 -> {
            if (resultCode != Activity.RESULT_OK) return
            val uri = this.cameraUri ?: return
            val orientation = uri.getOrientationWithGradle() // 画像の向きによって Int が返却される
        }
    }
}

事の経緯

問題1

ExifInterface(uri.path) で orientation の値が常に 0 が返却されてしまう、というのが始まりでした。画像の向きの取得方法を調べてみると、uri.path で filePath が取得できるから ExifInterface 使って Exif 情報をゲットできるといった情報があったので、ギャラリーから選択した画像の uri を取得して以下のように実装してみました。

val uri = data.data // data.data はギャラリー経由で取得した画像の Uri
val exif = ExifInterface(uri.path)
val orientation = Integer.parseInt(exifInterface.getAttribute(ExifInterface.TAG_ORIENTATION))

ところが、画像の向きが 90 度であっても常に orientation の値が 0 になってしまう問題が起きました。色々と調べてみると端末によりそのような事象になるようで、別の方法が StackOverFlow などで紹介されていたので試してみることにしました。

問題2

contentResolver を利用して filePath を取得しようとすると READ_EXTERNAL_STORAGE パーミッションが必要となる

別の方法というのが contentResolver を利用して画像の情報を DB に問い合わせ、uri.path では取得できない実際のストレージパスを取得して Exif 情報を取ってくるといった方法でした。

val uri = data.data // data.data はギャラリー経由で取得した画像の Uri

val contentResolver = SampleApplication.instance.contentResolver
val wholeId = DocumentsContract.getDocumentId(uri)
val id = if (wholeId.contains(":")) {
    wholeId.split(":")[1]
} else {
    wholeId
}
val sel = MediaStore.Images.Media._ID + "=?"
val columns = arrayOf(MediaStore.Images.Media.DATA)

// EXTERNAL_CONTENT_URI にアクセスする為には READ_EXTERNAL_STRAGE の許可が必要
val cursor = contentResolver.query(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, columns, sel, arrayOf(id), null)
var filePath: String? = null
if (cursor.moveToFirst()) {
    val columnIndex = cursor.getColumnIndex(columns[0])
    if (columnIndex != -1) {
        filePath = cursor.getString(columnIndex)
    }
}
cursor.close()
filePath?.let {
    val exif = ExifInterface(it)
    val orientation = Integer.parseInt(exifInterface.getAttribute(ExifInterface.TAG_ORIENTATION))
}

この方法で、確かにギャラリー経由で取得した画像の Uri を元に、画像の向き情報である orientation の値が取得できました(画像の向きが 90 度であれば 6 の数字を得られる)。しかし、この実装の問題点として EXTERNAL_CONTENT_URI にアクセスする為には READ_EXTERNAL_STORAGE パーミッションが必要になることが分かりました。画像の表示には Glide を利用していたのですが Glide にギャラリーから取得した uri を渡してあげると READ_EXTERNAL_STORAGE のパーミッションなしに、画像の向き情報を考慮して表示ができています。カメラで撮影した画像の uri を取得するのに WRITE_EXTERNAL_STORAGE のパーミッションを取得していたので、気持ち悪いけど、まぁ良しとしようかなぁと思いながら、カメラのテストをしていた時に、さらなる問題が発生しました。

問題3

contentResolver を利用して filePath を取得するが、ギャラリーとカメラで違う実装が必要になる

カメラで撮影した画像の Uri を this.cameraUri = CameraUtility.cameraUri(activity = this) として実装しているのですが cameraUri を元に getDocumentId で id を取得しようとすると Exception が発生するといった問題でした。

this.cameraUri = CameraUtility.cameraUri(activity = this)
val contentResolver = SampleApplication.instance.contentResolver
val wholeId = DocumentsContract.getDocumentId(uri) // Exception が発生

色々と試したところ、カメラで撮影した画像の orientation は以下で取得できることが分かりました。

val uri = this.cameraUri // カメラで撮影した画像の Uri

val contentResolver = SampleApplication.instance.contentResolver
var filePath: String? = null
val cursor = contentResolver.query(uri, null, null, null, null)
if (cursor.moveToFirst()) {
    if (cursor.moveToFirst()) {
        val index = cursor.getColumnIndex(MediaStore.Images.Media.DATA)
        if (index != -1) {
            filePath = cursor.getString(index)
        }
    }
}
cursor.close()
filePath?.let {
    val exif = ExifInterface(it)
    val orientation = Integer.parseInt(exifInterface.getAttribute(ExifInterface.TAG_ORIENTATION))
}

動作はするものの、この時点で Android の filePath はどのような構成になっているかが分からなくなってきました。ギャラリーとカメラで orientation の取得方法が異なる時点で、大分やられていましたが、トドメは、ギャラリーのダウンロードフォルダから画像を選択した場合に orientation が取得できないといった問題でした。

問題4

ギャラリー経由で取得した画像の Uri を元に orientation の値を取得する以下の実装ですが、ギャラリーのダウンロードフォルダから画像を選択した場合 cursor.moveToFirst() が false になるといった問題が起きました。 DocumentsContract.getDocumentId(uri) で画像の ID はきちんと取得できているのですが上手く行きません。

val uri = data.data // data.data はギャラリーのダウンロードフォルダにある画像の Uri

val contentResolver = SampleApplication.instance.contentResolver
val wholeId = DocumentsContract.getDocumentId(uri)
val id = if (wholeId.contains(":")) {
    wholeId.split(":")[1]
} else {
    wholeId
}
val sel = MediaStore.Images.Media._ID + "=?"
val columns = arrayOf(MediaStore.Images.Media.DATA)

val cursor = contentResolver.query(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, columns, sel, arrayOf(id), null)
var filePath: String? = null
if (cursor.moveToFirst()) { // false になる

色々と試して見た結果、ダウンロードフォルダから選択した画像は以下の実装で orientation を取得できることが分かりました。

val uri = data.data // data.data はギャラリーのダウンロードフォルダにある画像の Uri
val id = DocumentsContract.getDocumentId(uri)

val contentUri = ContentUris.withAppendedId(Uri.parse("content://downloads/public_downloads"), id.toLong())
val contentResolver = SampleApplication.instance.contentResolver
val columns = arrayOf(MediaStore.Images.Media.DATA)
var filePath: String? = null
val cursor = contentResolver.query(contentUri, columns, null, null, null)
if (cursor.moveToFirst()) {
    val index = cursor.getColumnIndex(columns[0])
    filepath = cursor.getString(index)
}
cursor.close()
filePath?.let {
    val exif = ExifInterface(it)
    val orientation = Integer.parseInt(exifInterface.getAttribute(ExifInterface.TAG_ORIENTATION))
}

もう何がなんだか分からなくなりました。画像の向き情報が欲しいだけなのに、考慮することが多すぎて、ライブラリに依存する形になってしまいますが Glide さんのお力を借りようと決意しました。

Glide を利用した orientation を取得する実装

Glide にギャラリーから取得できる Uri を渡したところ、画像の向き情報を取得するメソッドは ImageHeaderParser(exceptionStream).orientation だということが分かりました。実行されるメソッドを順に辿り uri の inputStream を渡してあげることで orientation が無事に取得できるようになりました。

fun Uri.getOrientationWithGradle(): Int {
    val byteArrayPool = ByteArrayPool.get()
    byteArrayPool.bytes
    val byteForStream = byteArrayPool.bytes
    val contentResolver = SampleApplication.instance.contentResolver
    val inputStream = contentResolver.openInputStream(this)
    val bufferedStream = RecyclableBufferedInputStream(inputStream, byteForStream)
    val exceptionStream = ExceptionCatchingInputStream.obtain(bufferedStream)
    val orientation = ImageHeaderParser(exceptionStream).orientation
    inputStream.close()
    return orientation
}

Glide の orientation を取得する実装を見たのですが、理解力が足りなく良く分かりませんでした。今後の課題として実装の内容は少しずつ紐解いて行きたいと思います。

(Android)動的に変化する TextView にもっと読むボタンを設置する

アクトインディ Advent Calendar 2016 14日目の記事になります。子どもとおでかけ情報アプリ「いこーよ」は、マップ上で簡単におでかけ先を探せる検索アプリです。記事を見てくれたパパさん、ママさん、ぜひ一度使ってみてください^^

iko-yo.net

今回は TextView の行数によって、もっと読むボタンを表示する機能の紹介をしたいと思います。API を利用してコンテンツを取得する際、長文の場合は 3 行ぐらい表示して「もっと読む」ボタンを置いて、ボタンをタップしたら開閉したい場合があるかと思います。固定されたテキストであれば xml ファイルに maxLines を指定して、ボタンを押したら maxLines に大きな値をセットすれば良いので実装はとても簡単なんですが、API から取得する場合は、 3 行未満のケースも考慮が必要な為、一工夫必要になります。実装したい内容は以下です。サンプルは GitHub で公開しています andridReadMoreSample

f:id:t-namikata:20161212235932g:plain

  1. コンテンツが 3 行を超える場合は、末尾を「...」で省略して、「もっと読む」ボタンを追加する
  2. 「もっと読む」ボタンをタップすると、コンテンツが全文表示されて、「もっと読む」ボタンは消える
  3. コンテンツが 3 行以下の場合は、「もっと読む」ボタンは表示せず「...」の表示もない

1. コンテンツが 3 行を超える場合は、末尾を「...」で省略して、「もっと読む」ボタンを追加する

Android には maxLines に指定した行数を超えると、末尾に「...」を表示する便利なプロパティがあるので、それを xml で指定してあげるだけで、簡単に実装することができます。

  • maxLines で行数を指定
  • maxLines を超えた場合は ellipsize="end" で末尾に ... を表示
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    tools:context="test.readmoresample.MainActivity"

    ... 省略 ...

    <TextView
        android:id="@+id/content"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:ellipsize="end"
        android:maxLines="3"
        android:text="@string/long_content" />

    <Button
        android:id="@+id/read_more"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="もっと読む" />

    ... 省略 ...

</LinearLayout>

2. 「もっと読む」ボタンをタップすると、コンテンツが全文表示されて、「もっと読む」ボタンは消える

maxLines に Integer.MAX_VALUE をセットしてあげることで content の全文を表示され ellipsize の ... も表示されなくなります。

class MainActivity : AppCompatActivity() {
    val contentTextView: TextView by bindView(R.id.content)
    val readMoreButton: Button by bindView(R.id.read_more)

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

        this.setUpContentTextView()

        this.readMoreButton.setOnClickListener {
            this.contentTextView.maxLines = Integer.MAX_VALUE
            this.readMoreButton.visibility = View.GONE
        }
    }
}

3. コンテンツが 3 行以下の場合は、「もっと読む」ボタンは表示せず「...」の表示もない

コンテンツの内容が動的に変更される場合は、コードで maxLines を上手い具合にセットしてあげる必要があり、条件は以下の 3 つに分類されます。

  1. contentTextView.lineCount > 3 である場合は maxLines に 3 をセットして「もっと読む」ボタンを表示する
  2. contentTextView.lineCount <= 3 であり、実際のコンテンツも 3 行以下の場合は「もっと読む」ボタンを表示しない
  3. contentTextView.lineCount <= 3 であるが maxLines = 3 の設定により折りたたまれていて、実際のコンテンツは 3 行以上の場合は「もっと読む」ボタンを表示する
1. contentTextView.lineCount > 3 である場合は maxLines に 3 をセットして「もっと読む」ボタンを表示する

こちらのチェックは簡単です。 this.contentTextView.post の中で lineCount を行わないと onCreate メソッドなどで実行してしまうと View の状態がまだ確定していない為 lineCount は常に 0 が返ってきてしまうので注意しましょう。

private fun setUpContentTextView() {
    val MAX_LINES = 3

    this.contentTextView.post {
        val needTruncate = (contentTextView.lineCount > MAX_LINES)
        if (needTruncate) {
            this.contentTextView.maxLines = MAX_LINES
            this.readMoreButton.visibility = View.VISIBLE
            return@post
        }
    }
}
2. contentTextView.lineCount <= 3 であり、実際のコンテンツも 3 行以下の場合は「もっと読む」ボタンを表示しない
3. contentTextView.lineCount <= 3 であるが maxLines = 3 の設定により折りたたまれていて、実際のコンテンツは 3 行以上の場合は「もっと読む」ボタンを表示する

次に、すでに maxLines = 3 の指定により、コンテンツが折りたたまれているかどうかを判別して、処理を分岐します。

private fun setUpContentTextView() {
    val MAX_LINES = 3

    this.contentTextView.post {
        ... 省略 ...
    }

    if (contentTextView.isTextTruncated()) {
        this.readMoreButton.visibility = View.VISIBLE
    } else {
        this.readMoreButton.visibility = View.GONE
    }
}

今回は TextView に Extension で実装を追加しました。 getEllipsisCount メソッドにより textView が maxLines を超えているかどうかを判別することができます。

fun TextView.isTextTruncated(): Boolean {
    val layout = this.layout ?: return false
    val lines = layout.lineCount
    if (lines < 1) return false
    val ellipsisCount = layout.getEllipsisCount(lines - 1)
    return ellipsisCount > 0
}

これで contentTextView にセットされた文の長さによって、表示の切り替えが行えるようになったので、あとは API で結果を取得して TextView に値を反映したタイミングで this.setUpContentTextView() を呼び出せば contentTextView にセットされた文の長さによって、コンテンツの省略ともっと読むボタンが表示されるようになります。

今回は「長文をセット」「短文をセット」ボタンにより、表示の切り替えサンプルを作ってみたので、最後にそのコードを紹介したいと思います。

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    tools:context="test.readmoresample.MainActivity"

    ... 省略 ...

    <TextView
        android:id="@+id/content"

    <Button
        android:id="@+id/read_more"

    ... 省略 ...

    <LinearLayout
        android:layout_marginTop="@dimen/activity_vertical_margin"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:orientation="horizontal">

        <Button
            android:id="@+id/long_content"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="長文をセット" />

        <Button
            android:id="@+id/short_content"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="短文をセット" />
    </LinearLayout>

</LinearLayout>
class MainActivity : AppCompatActivity() {
    val contentTextView: TextView by bindView(R.id.content)
    val readMoreButton: Button by bindView(R.id.read_more)
    val setLongContentButton: Button by bindView(R.id.long_content)
    val setShortContentButton: Button by bindView(R.id.short_content)

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

        this.setUpContentTextView()

        this.readMoreButton.setOnClickListener {
            this.contentTextView.maxLines = Integer.MAX_VALUE
            this.readMoreButton.visibility = View.GONE
        }

        this.setLongContentButton.setOnClickListener {
            this.contentTextView.text = this.getString(R.string.long_content)
            this.setUpContentTextView()
        }

        this.setShortContentButton.setOnClickListener {
            this.contentTextView.text = this.getString(R.string.short_content)
            this.setUpContentTextView()
        }
    }

    private fun setUpContentTextView() {
        val MAX_LINES = 3

        this.contentTextView.post {
            val needTruncate = (contentTextView.lineCount > MAX_LINES)
            if (needTruncate) {
                this.contentTextView.maxLines = MAX_LINES
                this.readMoreButton.visibility = View.VISIBLE
                return@post
            }

            if (contentTextView.isTextTruncated()) {
                this.readMoreButton.visibility = View.VISIBLE
            } else {
                this.readMoreButton.visibility = View.GONE
            }
        }
    }
}

メソッド数調査、エミュレーターのファイルダウンロードなどを楽にする Android プロジェクト用のシェルスクリプト

アクトインディ Advent Calendar 2016 13日目の記事になります。子どもとおでかけ情報アプリ「いこーよ」は、マップ上で簡単におでかけ先を探せる検索アプリです。記事を見てくれたパパさん、ママさん、ぜひ一度使ってみてください^^

iko-yo.net

今回の記事は Android プロジェクト用に利用しているシェルスクリプトについて書きたいと思います。一つ一つは簡単なコマンドですが、頻繁に利用しないとすぐコマンド忘れてしまうので、覚えなくても良いコマンドはシェルスクリプトに書くようにしています。

現在のメソッド数を確認する

ライブラリを導入する際は、導入前、導入後でメソッド数を計測し、導入すべきか検討するようにしているので、開発初期は結構使います。./gradlew clean assembleDebug はいらないかもしれません。

dex.sh

./gradlew clean assembleDebug
~/tools/dex-method-counts/dex-method-counts app/build/outputs/apk/app-debug.apk
  • dex-method-counts をインストールする場所は任意です
  • プロジェクト直下に dex.sh を置いていることを想定した path です

実行結果

Read in 49829 method IDs.
<root>: 49829
    <default>: 1
    a: 22
    android: 14348
        accounts: 3

    ...

Overall method count: 49829

dex-method-counts の導入方法

github に公開されているソースを適当なディレクトリにダウンロードするだけです

今回の例では ~/tools にインストールすることにします

cd ~/tools
git clone https://github.com/mihaip/dex-method-counts.git

Realm ファイルを adb でダウンロードする

Realm に格納されているデータを見たい時に便利です。RealmBrowser を入れている場合は、実行後に open default.realm で RealmBrowser を開くことができます。

fetch_realm.sh

adb kill-server
adb root
adb pull /data/data/com.example.sample/files/default.realm

実行結果

シェルを実行すると以下のファイルがダウンロードされます。

  • default.realm
  • default.realm.lock
  • default.realm.management/

keyhash の取得

Firebase や Line SDK を導入する際に必要になるのですが、利用するシーンが少なく、すぐ忘れてしまうので。Debugビルド用です。

key-hash.sh

keytool -exportcert -alias androiddebugkey -keystore ~/.android/debug.keystore -list -v -storepass android

実行結果

別名: androiddebugkey
作成日: 2017/01/01
エントリ・タイプ: PrivateKeyEntry
証明書チェーンの長さ: 1
証明書[1]:
所有者: C=US, O=Android, CN=Android Debug
発行者: C=US, O=Android, CN=Android Debug
シリアル番号: 1
有効期間の開始日: Mon Sep 30 10:00:00 JST 2017終了日: Wed Sep 23 10:00:00 JST 2100
証明書のフィンガプリント:
   MD5:  ************
   SHA1: ************
   SHA256: ************
   署名アルゴリズム名: ******
   バージョン: 1

Git のブランチ整理

プルリクエストが master にマージされた後などに良く実行しています。master ブランチを最新にして、既に master ブランチにマージされたローカルの作業ブランチを削除してくれます。

pull_and_fetch.sh

git checkout master && git pull origin master && git fetch -p origin && git branch --merged | grep -vE '^\*|master$|develop$' | xargs -I % git branch -d %

その他

プロジェクトの git の管理対象にならないように .git/info/exclude に以下のように設定して git 管理対象外になるようにして利用しています。

pull_and_fetch.sh
fetch_realm.sh
default.realm
default.realm.lock
default.realm.management/
dex.sh
key-hash.sh

Realm InMemory を使って Activity 間でのデータ同期をスムーズに行う

アクトインディ Advent Calendar 2016 9日目の記事になります。子どもとおでかけ情報アプリ「いこーよ」は、マップ上で簡単におでかけ先を探せる検索アプリです。記事を見てくれたパパさん、ママさん、ぜひ一度使ってみてください^^

iko-yo.net

Kotlin 1.0.5
Realm 2.2.2

一覧と詳細画面があって、詳細画面でいいね!して、戻ったら一覧画面にも値を反映させたい。といった事はアプリを作っていたら良くある事だと思います。今回の記事では Realm のオートリフレッシュとリスナーを使って、異なる Activity 間でもデータの変更を反映する方法を書きたいと思います。せっかく Realm を使うので、API で取得した結果をアプリが終了するまでキャッシュして利用するようにしたいと思います。

以下を想定して書きたいと思います。

f:id:t-namikata:20161208001656g:plain

  • 一覧と詳細のデータは API を利用したサーバーから取得する
  • 取得した結果は Realm に保存し、アプリケーションが終了するタイミングで破棄する
  • 詳細で「いいね」したら、戻るボタンで一覧に戻るといいねした結果が反映されている
  • 詳細で「削除」したら、戻るボタンで一覧に戻ると削除したアイテムが消えている

Realm を利用すると Activity をまたいだデータの同期や、ListView の item の増減が簡単に行える利点があります。

ポイントを先にまとめると以下になります。

  1. アプリケーションが終了したらデータを破棄するには Realm InMemory を利用する
  2. List の Adapter を作成する際には RealmBaseAdapter を継承する
  3. Realm から取得した情報はマネージドオブジェクトになり、オートリフレッシュや値の変更を検知できることを知っておく
  4. オートリフレッシュでオブジェクトの値が自動で更新されても、TextView にセットした text は自動で更新されないことを知っておく

それでは、実際にソースを書いていきたいと思います。完成版は github に公開しています。 https://github.com/takanamishi/AndroidRealmSample

1. アプリケーションが終了したらデータを破棄するには Realm InMemory を利用する

まずは InMemory な Realm のセットアップです。公式ドキュメントに書いてある通りです。アプリケーションのサイクルを管理するアプリケーションクラスを継承したサブクラスを作成します。 Realm.setDefaultConfiguration(config) することで Realm.getDefaultInstance() で取得される Realm が config でセットしたものになるので InMemory と Disk に保存する通常の Realm とで書き方は変わりません。

class RealmSampleApplication : Application() {
    /**
     * キャッシュデータ用のRealmオブジェクト
     * インスタンスを保持しつづけていないとRealmの仕様でキャッシュデータが消えてしまうため
     * アプリケーションクラスで保持する
     */
    lateinit var inMemoryRealm: Realm

    companion object {
        lateinit var instance: RealmSampleApplication
    }

    init {
        instance = this
    }

    override fun onCreate() {
        super.onCreate()

        // InMemory な Realm の config を設定する
        Realm.init(this)
        val config: RealmConfiguration = RealmConfiguration.Builder()
                .name("inMemory.realm")
                .inMemory()
                .build()
        this.inMemoryRealm = Realm.getInstance(config)
        Realm.setDefaultConfiguration(config)
    }
}

2. List の Adapter を作成する際には RealmBaseAdapter を継承する

リストの要素となる Adapter を作成します。Realm の公式ドキュメントでも説明があるように、ListView を利用する際には、Realm が用意してくれている RealmBaseAdapter を継承するようにします。RealmBaseAdapter を継承することで、リストに変更があった場合に、変更を反映するコードを書くことなく、リストが更新されます。

ここでのポイントは RealmResults 型のインスタンスを引数で受け取ることです。initialize で指定した realmResult は adapterData に格納されるので、要素を参照したい時は adapterData を参照します。他の部分は通常の ListView と同様です。

class ItemAdapter(context: Context, realmResult: RealmResults<Item>): RealmBaseAdapter<Item>(context, realmResult), ListAdapter {
    override fun getView(position: Int, convertView: View?, parent: ViewGroup?): View {
        val view = convertView ?: LayoutInflater.from(context).inflate(R.layout.item, parent, false).apply {
            this.tag = ViewHolder(this)
        }
        val holder = view.tag as ViewHolder
        val data = this.adapterData ?: return view
        val item = data[position]
        holder.setUp(item = item)

        return view
    }

    private class ViewHolder(view: View) {
        private val nameTextView = view.findViewById(R.id.name) as TextView
        private val likesCountTextView = view.findViewById(R.id.likesCount) as TextView

        fun setUp(item: Item) {
            this.nameTextView.text = item.name
            this.likesCountTextView.text = item.likesCount.toString()
        }
    }
}

Realm から取得した情報はマネージドオブジェクトになり、オートリフレッシュや値の変更を検知できることを知っておく

Adapter ができたので、MainActivity でリストを表示します。ここでのポイントは 1 点だけで、リスト情報( items )は、Realm データベースから取得するといった点です。Realm に対して where で取得した結果は、マネージドオブジェクトとなり、値に変更があった際のオートリフレッシュや変更の検知を行うことができるようになります。private val items: RealmResults<Item> = realm.where(Item::class.java).findAll()と、プロパティ宣言時に Realm から値を取得しているのもその為です。findAll() メソッドは、Realm にデータがなくても RealmResults 型の空配列を返してくれるので Item が Realm に追加、削除、変更が加わった際に items の値は自動で更新されるようになります。

今回の例では、プロパティ宣言時にprivate val items: RealmResults<Item> = realm.where(Item::class.java).findAll() では空の RealmResults が items に代入されますが saveRealmData() で Realm に Item を保存するだけで、画面にリストが表示されます。

setUpView() メソッドを呼んだ時点では items の中身は RealmResults 型の空配列になっていますが setUpRealmInitData で item に変更が加わると items が更新され、 RealmBaseAdapter を継承した ItemAdapter が変更を検知して、リストを更新してくれるという訳です。便利ですね。

class MainActivity : AppCompatActivity() {
    private val realm = Realm.getDefaultInstance()
    private val items: RealmResults<Item> = realm.where(Item::class.java).findAll()
    private val listView: ListView by bindView(R.id.list_view)

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

        this.title = "将来なりたい職業"
        this.setUpView()

        // 一覧を取得する API をコールする
        // 結果が取得できたら Realm に保存する
        // 今回は説明を簡単にする為 API 経由で情報の取得が完了したとして、データをセットします。
        this.saveRealmData()
    }

    override fun onDestroy() {
        super.onDestroy()
        this.realm.close()
    }

    private fun setUpView() {
        val adapter = ItemAdapter(context = this, realmResult = items)
        this.listView.adapter = adapter
        this.listView.setOnItemClickListener { adapterView, view, position, id ->
            val item = this.items[position]
            val intent = Intent(this, ItemActivity::class.java)
            intent.putExtra("itemId", item.id)
            this.startActivity(intent)
        }
    }

    private fun saveRealmData() {
        val pilot = Item(id = 1, name = "パイロット", content = "空を飛びたい", likesCount = 7)
        val carpenter = Item(id = 2, name = "大工", content = "自分の家を建てたい", likesCount = 4)
        val programer = Item(id = 3, name = "プログラマー", content = "キーボードが好き", likesCount = 9999)

        this.realm.executeTransaction {
            realm.copyToRealmOrUpdate(pilot)
            realm.copyToRealmOrUpdate(carpenter)
            realm.copyToRealmOrUpdate(programer)
        }
    }
}

オートリフレッシュでオブジェクトの値が自動で更新されても、TextView にセットした text は自動で更新されないことを知っておく

最後に、リストをタップした際に表示される ItemActivity を作成して完成です。ここでのポイントは、オートリフレッシュで値が更新されても textView にセットした値は更新されないといった点です。今回の例では ItemActivity のいいねボタンが押されると item.likesCount を +1 して Realm に保存しています。item は realm.where(Item::class.java).equalTo("id", itemId).findFirst() で Realm からデータを取得しているのでマネージドオブジェクトとなり、オートリフレッシュの対象となります。this.likesCountTextView.text = item.likesCount.toString() と textView にいいね数を反映していますが item.likesCount がインクリメントされても textView の値は変更されません。このようなケースでは item の変更を addChangeListerner で検知して textView に値を再セットしてあげる必要があります。

class ItemActivity : AppCompatActivity() {
    private val realm = Realm.getDefaultInstance()
    private val contentTextView: TextView by bindView(R.id.content)
    private val likesCountTextView: TextView by bindView(R.id.likes_count)
    private val likeButton: Button by bindView(R.id.like)
    private val deleteButton: Button by bindView(R.id.delete)
    private lateinit var item: Item

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_item)

        val itemId = this.intent.getIntExtra("itemId", 0)
        this.item = this.realm.where(Item::class.java).equalTo("id", itemId).findFirst() ?: return

        item.addChangeListener<Item> {
            this.likesCountTextView.text = item.likesCount.toString()
        }

        this.setUpViews(item = item)

        this.likeButton.setOnClickListener {
            this.realm.executeTransaction {
                item.likesCount += 1
            }
        }

        this.deleteButton.setOnClickListener {
            this.realm.executeTransaction {
                item.deleteFromRealm()
            }
        }
    }

    override fun onDestroy() {
        super.onDestroy()
        this.item.removeChangeListeners()
        this.realm.close()
    }

    private fun setUpViews(item: Item) {
        this.contentTextView.text = item.name
        this.likesCountTextView.text = item.likesCount.toString()
    }
}

まとめ

Realm を採用していいなと思った点

  • Realm のオートリフレッシュの機能により、リストを更新するコードを書く必要がなく、コードの見通しが良くなる。
  • Realm の監視を解除するのが onDestory なので Activity が生きている限り、フロントにいない Activity でも、値の変更を検知して画面を更新することができる
  • Realm をキャッシュとして利用することで API のコールを減らせる

Realm の使いづらいなと思った点

  • realm.close 面倒。close 忘れが怖い。
  • メインスレッド以外で Realm にアクセスするのが辛い
  • Model の定義で var と val を間違えた時や、初期値を設定していない時、open をつけ忘れた時など、以下のエラーになって気づきにくい
Error:Execution failed for task ':app:compileDebugJavaWithJavac'.
> Compilation failed; see the compiler error output for details.

Kotlin で Realm を操作する時に気をつけていること

アクトインディ Advent Calendar 2016 4日目の記事になります。「若者の結婚願望 子供時代、体験多いと強く 」といった記事が日本経済新聞に書かれていました。

www.nikkei.com

子ども時代に家族で沢山思い出を作っていると、将来、結婚して家庭を持ちたいという気持ちが強くなるといった結果が出たそうです。アクトインディでは、子どもとのお出かけ先を提案するサービスを運営していますが、家族の思い出作りに少しでもお役に立てるサービスにもっと良くしていこうと思える嬉しい記事でした。子どもとのお出かけ先に困っている方は、是非いこーよアプリを使ってみてください ^^

iko-yo.net

4日目の記事では kotlin で Realm を操作する時に気をつけていることについて書きたいと思います。kotlin は null安全の言語ですが、Realm は java で書かれている為、 Realm から要素を取得する際 kotlin の型推論を使って以下のように書くと、要素がない場合に NullPointerExceptionが発生します。

open class User(
    @PrimaryKey open var id: Int = 0,
    open var name: String = ""
)
val realm = Realm.getDefaultInstance()
val user = realm.where(User::class.java).findFirst()
Log.d("*****", user.id.toString()) // user は null の為 NullPointerException
realm.close()

realm の findFirst() メソッドの定義が public E findFirst() { ... } となっているので、 val user の部分は型推論が行われ、実際には val user: User = realm.where(User::class.java).findFirst() と宣言していることになります。

kotlin でも安全に使う為には、型推論を行わず以下のように書くことで null が返却されることを明示することができますが

val realm = Realm.getDefaultInstance()
val user: User? = realm.where(User::class.java).findFirst()
Log.d("*****", user?.id.toString()) // null と Log に表示される
realm.close()

val user: User と書くことができてしまう為、少しでも安全に Realm を操作できるように Realm の Extension を定義して、極力 Extension で定義したメソッドを利用するようにしています。

RealmExtension.kt

// nullable な要素を返却するようにメソッドを定義する
fun <T : RealmObject> Realm.findFirst(type: Class<T>): T? {
    return this.where(type).findFirst()
}

fun <T : RealmObject> Realm.findId(type: Class<T>, id: Int): T? {
    return this.where(type).equalTo("id", id).findFirst()
}

Extension で定義したメソッドを利用するには以下のように書きます

val realm = Realm.getDefaultInstance()
val user = realm.findFirst(User::class.java)
Log.d("*****", user?.id.toString()) // null と Log に表示される
realm.close()

PrimaryKey に良く指定する id で要素を検索することが多いので findId といったメソッドも定義しています。Realm には要素を全て取得する findAll メソッドがありますが、こちらは要素が1件もなかった場合は空の配列で返却されるので、特にメソッドを定義する必要はないかと思います。