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

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

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.