Realmと使うAndroid Architecture Components

Google I/O 2017では様々な発表が行われました。 1つのブログポストではとてもその全てをお伝えすることはできませんが、最も私が注目するのがAndroid Architecture Componentsです(もちろんKotlin以外でですよ 😉)。

ついにAndroidアプリにおいてGoogleが推奨するアーキテクチャのガイドが公開されました。Googleが推奨するアーキテクチャはモジュール化された柔軟なものになっているので、必要に応じてさまざまなモジュールやフレームワークを組み込むことができます。

Yiğit Boyarによるセッションでは、以下のものについて具体的な使い方が紹介されました。これら以外にも今後随時公開されていくとのことです。

  • Room
  • ViewModel
  • LiveData
  • Lifecycle

これらについての動画をまだ見ていない人は、ぜひご覧になることをお勧めします。いくつかありますが、こちらの動画に概要が短くまとめられています。

Android Architecture Componentsが提供するこれらの仕組みが、Realmを使ったアプリ開発においてどのように役立つか見ていきましょう。

Room

RoomはGoogleが今回新たに提供するORMで、SQLiteの上に構築されています。Roomは、これまでのSQLiteのAPIを飛躍的に改善します。名前もなかなかいいですよね 😉

他の多くのORMとは違って、Roomはクエリを発行する際に直接SQLを記述する必要があり、関連オブジェクトの遅延ロードもサポートしていません。ですが実際はこれらは他のように裏でSQLを自動生成するORMに対する強みでもあります。遅延ロードやSQLを書いてメンテナンスしていく必要がないという点は初めはとても魅力的に感じます。しかし使い込んでいくにつれ、オブジェクト間の関連をたどる必要が発生し自動生成のSQLによる巨大で非効率的なSQL文のパフォーマンス問題に直面する場合があります。

結局自分でクエリのSQLをメンテナンスしていくことになるのですが、RoomはSQL文を@Queryアノテーションに書くようにすることでとてもわかりやすい形に保つことができます。このアノテーションをData Access Object(DAO)インタフェースのメソッドに付与し、メソッドの名前と返り値を決めるだけです。あとはRoomが自動的に必要な実装をコード生成してくれます。

以下に簡単な例を載せておきます。

@Query("SELECT Loan.id, Book.title as title, User.name as name, Loan.startTime, Loan.endTime " +
       "FROM Book " +
       "INNER JOIN Loan ON Loan.book_id = Book.id " +
       "INNER JOIN User ON User.id = Loan.user_id " +
       "WHERE User.name LIKE :userName " +
       "AND Loan.endTime > :after "
)
public LiveData<List<LoanWithUserAndBook>> findLoansByNameAfter(String userName, Date after);

@Query("SELECT * From Loan")
LiveData<List<Loan>> findAll();

SQLiteをAndroid上のローカルストレージとして利用しているのであれば、Roomはその使い方を大きく改善してくれます。さらに詳しい情報は、Googleが公開しているRoomドキュメンテーションを参照してください。

ViewModel

Googleが今回提供するViewModelはActivityやFragmentからUIに関連するデータにアクセスするために設計されていて、LiveDataクラスやライフサイクルという概念と強く結びつけられています。このViewModelが、Androidアプリの書き方を大きく変えてくれるものになります。

最も便利な点は、ViewModelがライフサイクルとうまく関連づけられているため、画面回転のようなconfiguration changeによるActivityインスタンスの再生成を超えてViewModelのインスタンスが維持されるという点です。これは長らくAndroidアプリ開発者を悩ませていた問題の一つを解決してくれます。公式ドキュメントの図にあるように、ViewModeは概念としてのActivityが終了されるまで生き続けます。

ViewModel Lifecycle Diagram

Realmを使う際は、ViewModelにRealmインスタンスのライフサイクルを管理させ、ViewModelが破棄されるタイミングでRealmインスタンスをcloseさせるという使い方ができます。

これがうまくいくのは、Architecture Componentsが提供するライフサイクル及びライフサイクルに対応したコンポーネントという設計のおかげです。

記事の更新情報を受け取る

Lifecycle

Androidフレームワークが提供する多くのコンポーネントはライフサイクルという概念を持っています。Realmも同様に、Realm.getDefaultInstance()で取得したインスタンスが不要になったら必ずrealm.close()を呼び出さなければなりません。また、クローズする前に全てのchange listenerを解除するのと、開始したトランザクションを閉じておくことも必要です。今回提供されたLifecycleパッケージは、ライフサイクルを扱うコンポーネントの開発を簡単にしてくれるクラスやインターフェースを提供してくれます。実際にどのように使うかについては後ほど説明することとして、先にLiveDataについて取り上げます。

LiveData

LiveDataは、ActivityからポーリングすることなしにViewModel側で発生したデータの変更をUIに反映させるための仕組みです。LiveDataを使うことで、VideModelActivityFragmentの参照を保持することなしに画面更新を実現することができます。Activity側からLiveDataに関連づけを行うことで、データの変更を受け取ることができるようになります。

Googleがライブデータという考え方を取り入れたことはとてもすばらしい事です。また、これはRealmが持つ変更通知受け取り可能なライブデータという特性ともとてもよくマッチします。

Architecture Componentsが素晴らしいのはそのpluggableな設計です。どのようなデータであってもLiveDataとして扱うことができます。どのようなコンポーネントでもライフサイクルと協調させることができ、アプリが必要とするViewModelを作ることができます。今回公開されたガイドとツールは本当に素晴らしいです 🎉

今回のGoogle I/Oでのコードラボに含まれるandroid-persistenceを使って、Realmがこれらのコンポーネントとどのように組み合わさるかを見ていきましょう。コードラボで出て来るSQLiteのテーブル構造をRealmのデータモデルで置き換えてみます。置き換え前と後で以下のようになります。

android-persistence DataModel Comparison

最も大きな違いはクラス間の関連です。Realmでは、UserBookLoan(ここでのLoanは本の貸し出しを意味しています)のリストを持っていると同時に、Loanは自身と関連づけられたUserBookへの参照を保持しています。それに対して、SQLiteではLoanテーブル内の外部キーとして保持され、のちに出てきますがSQLで記述されるクエリの中で結合されます。

それではモデル定義をRealmを使ったものに更新して見ましょう。以下にLoanモデルの変更前と変更後の例を示します。

LoanModelCode

クエリ

クエリについても書き換えます。例えばfindLoansByNameAfter()ですが、インターフェースメソッドとそのアノテーションとしてSQL書く方法から、メソッド本体でRealmQueryを構築する方法に書き換えます。

Architecture Components Switching to Realm DAO

それではこれらを組み合わせて全体としてどのようになるか見ていきましょう、

Activity

ActivityはLifecycleActivityを継承したクラスになっていますが、将来的にはAppCompatライブラリーに統合されることが予定されています。LifecycleActivityを継承することでViewModelのようなこれまで説明してきたLifecycleと協調するコンポーネントを使用することができるようになります。

public class CustomResultUserActivity extends LifecycleActivity {

    private CustomResultViewModel mShowUserViewModel;
    private TextView mBooksTextView;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.db_activity);
        mBooksTextView = (TextView) findViewById(R.id.books_tv);

        // 必要に応じてCustomResultViewModelインスタンスが生成されます。生成されたインスタンスは画面回転後も維持されます。
        mShowUserViewModel = ViewModelProviders.of(this).get(CustomResultViewModel.class);

        // 貸し出し情報の更新を監視します
        mShowUserViewModel.getLoansResult().observe(this, new Observer<String>() {
            @Override
            public void onChanged(@Nullable final String result) {
                mBooksTextView.setText(result);
            }
        });
    }

    public void onRefreshBtClicked(View view) {
        mShowUserViewModel.simulateDataUpdates();
    }
}

onCreateで、ViewModelProviders.of(...)を使ってViewModelインスタンスを取得している部分に注目してください。このようにすることで、ActivityのライフサイクルにおいてCustomResultViewModelが作られていなければ新たに作成されます。例えば画面回転の後のように、configuration changeによってActivityインスタンスが再生成された場合は、最初に作成されたViewModelインスタンスが再度返ってきます。また、Activityが終了されViewModelインスタンスが不要になったときには、onCleared()が呼び出されてリソースの解放を行うことができます。

このViewModelでは、mShowUserViewModel.getLoansResult()によって LiveData をActivityに渡し、Activityは LiveData の変更に対して通知を受け取るようにしています。onPause()onStop()で変更の通知を停止する処理は必要ありません。というのも、LiveDataはActivityのライフサイクルと連動して動作するため、通知の停止を自動的に行ってくれるからです。これを実現するために、.observe(...)の第一引数にActivityインスタンスを渡しています。

ViewModel

ViewModelはUIに関連するデータの管理と、そのデータに対する操作をActivityに対して提供します。例えばsimulateDataUpdates()のようなメソッドです。

public class CustomResultViewModel extends ViewModel {

    private Realm mDb;
    private LiveData<String> mLoansResult;

    public CustomResultViewModel() {
        mDb = Realm.getDefaultInstance();
        subscribeToMikesLoansSinceYesterday();
        simulateDataUpdates();
    }

    public void simulateDataUpdates() {
        DatabaseInitializer.populateAsync(mDb);
    }

    public LiveData<String> getLoansResult() {
        return mLoansResult;
    }

    private void subscribeToMikesLoansSinceYesterday() {
        LiveRealmData<Loan> loans = loanModel(mDb)
                .findLoansByNameAfter("Mike", getYesterdayDate());
        mLoansResult = Transformations.map(loans, new Function<RealmResults<Loan>, String>() {
            @Override
            public String apply(RealmResults<Loan> loans) {
                StringBuilder sb = new StringBuilder();
                SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm",
                        Locale.US);

                for (Loan loan : loans) {
                    sb.append(String.format("%s\n  (Returned: %s)\n",
                            loan.getBook().getTitle(),
                            simpleDateFormat.format(loan.getEndTime())));
                }
                return sb.toString();
            }
        });
    }

    /**
     * このメソッドは、このViewModelインスタンスが破棄されるタイミングで呼ばれます。
     * <p>
     * ViewModelが何らかのデータを監視していたりする場合など何らかの解放処理が必要な場合に最適です。
     * 例えばRealmインスタンスのcloseなどです。
     */
    @Override
    protected void onCleared() {
        mDb.close();
        super.onCleared();
    }

    private Date getYesterdayDate() {
        Calendar calendar = Calendar.getInstance();
        calendar.set(Calendar.DATE, -1);
        return calendar.getTime();
    }
}

Realm版のCustomResultViewModelコードラボ版のものととても似ていますが、一番大きな違いはLoanUserそれぞれにDAOを定義するのではなく、単に貸し出し(Loan)情報の参照のみを保持していて必要に応じてそこからUserBookを参照している点です。これはORMやSQLでは効率よく実現することはできません。Realmでは、このような制限はありません。Realmにおいて関連は追加のクエリや事前のJOINを発生させることなく実現されていて、ほとんどコストがかかりません。

次に定義するLiveRealmData<T>LiveData<RealmResults<T>>を簡単に扱えるようにするためのものです。

public class LiveRealmData<T extends RealmModel> extends LiveData<RealmResults<T>> {

    private RealmResults<T> results;
    private final RealmChangeListener<RealmResults<T>> listener = 
        new RealmChangeListener<RealmResults<T>>() {
            @Override
            public void onChange(RealmResults<T> results) { setValue(results);}
    };

    public LiveRealmData(RealmResults<T> realmResults) {
        results = realmResults;
    }

    @Override
    protected void onActive() {
        results.addChangeListener(listener);
    }

    @Override
    protected void onInactive() {
        results.removeChangeListener(listener);
    }
}

これはRealmResultsをライフサイクルと協調する形でLiveData化するラッパークラスです。

DAO

データベースとのやりとりをDAOの中に隠蔽するのは、他のコンポーネントとの相互運用性の確保やテストを容易にするという意味で良い方法です。例えば、モックのDAOを使うことで、ViewModelが実際のデータベースとは独立してテスト出来る様になります。 コードラボの例では、RoomDatabaseを継承したクラスにDAO取得のためのabstractメソッドとシングルトンインスタンス取得のためのファクトリーメソッドを定義しています。

@Database(entities = {User.class, Book.class, Loan.class}, version = 1)
public abstract class AppDatabase extends RoomDatabase {

    private static AppDatabase INSTANCE;

    public abstract UserDao userModel();
    public abstract BookDao bookModel();
    public abstract LoanDao loanModel();

    public static AppDatabase getInMemoryDatabase(Context context) {
        if (INSTANCE == null) {
            INSTANCE =
                    Room.inMemoryDatabaseBuilder(context.getApplicationContext(), AppDatabase.class)
                    // コードラボをシンプルなものにするため、メインスレッドでのクエリ実行を許可しています。
                    // 実際のアプリでは決してこのようにはしないでください。具体的な対処方法の例はPersistenceBasicSampleをみてください。
                    .allowMainThreadQueries()
                    .build();
        }
        return INSTANCE;
    }

    public static void destroyInstance() {
        INSTANCE = null;
    }
}

Realmではこのようなファクトリーメソッドを作成する必要がないので、Realmインスタンスから直接DAOを取得できるようにしてしまうと簡単です(RaelmインスタンスのライフサイクルはViewModelによって管理されるようにしているという前提です)。念のために書きますが、全てのRealmモデルのオブジェクトはそれを取得したRealmインスタンスのライフサイクルと結び付けられています。

これを実現するため、シンプルなRealmUtils.javaを作成することもできましたが、この機能が簡単に実現できるKotlinの拡張関数を使って書いてみました。

@file:JvmName("RealmUtils") // pretty name for utils class if called from Java

...
fun Realm.userModel(): UserDao = UserDao(this)
fun Realm.bookModel(): BookDao = BookDao(this)
fun Realm.loanModel(): LoanDao = LoanDao(this)

// Convenience extension on RealmResults to return as LiveRealmData
fun <T:RealmModel> RealmResults<T>.asLiveData() = LiveRealmData<T>(this)

Javaのコードからは、RealmUtils.bookDao(realm)のような形で利用可能です。もちろんKotlinから呼び出す場合は単にrealm.bookDao()とすることでBookDaoが取得可能です。

DAOの取得をできるようにしたところで、実際のDAOの中身を見ていきましょう。次のコードはCustomResultViewModel から使うために実装したLoanDaoの例です。このLoanDaoでは、本の貸し出し(Loan)を名前と日付を元に取得することができます。

public class LoanDao  {

    private Realm mRealm;

    public LoanDao(Realm realm) { this.mRealm = realm; }

    public LiveRealmData<Loan> findLoansByNameAfter(final String userName, final Date after) {
        return asLiveData(mRealm.where(Loan.class)
                .like("user.name", userName)
                .greaterThan("endTime", after)
                .findAllAsync());
    }

    public void addLoan(final Date from, final Date to, final String userId, final String bookId) {
        User user = mRealm.where(User.class).equalTo("id", userId).findFirst();
        Book book = mRealm.where(Book.class).equalTo("id", bookId).findFirst();
        Loan loan = new Loan(from, to, book, user);
        mRealm.insert(loan);
    }
}

最後に残った作業は、サンプルにあるライブデータの更新をシミュレートする処理を実装することです。

データ更新のシミュレーション

Activityが開始される際とrefreshボタンが押された際に、データは消去され、新たな擬似データがデータベースに投入されます。

SQLite exampleを元にRealm版を作成しました。Realm版は元のコードととてもよく似ていますが、AsyncTaskを使うのではなく、Realmが持っている非同期処理(Async)APIを使用しています。このような場合に私が書く実際のRealmアプリではIntentServiceを利用することもよくあります。

    // Simulate a blocking operation delaying each Loan insertion with a delay:
    private static final int DELAY_MILLIS = 500;

    public static void populateAsync(final Realm db) {

        Realm.Transaction task = populateWithTestDataTx;
        db.executeTransactionAsync(task);
    }

    private static Realm.Transaction populateWithTestDataTx = new Realm.Transaction() {
        @Override
        public void execute(Realm db) {

            db.deleteAll();
            checkpoint(db);

            User user1 = addUser(db, "1", "Jason", "Seaver", 40);
            User user2 = addUser(db, "2", "Mike", "Seaver", 12);
            addUser(db, "3", "Carol", "Seaver", 15);

            Book book1 = addBook(db, "1", "Dune");
            Book book2 = addBook(db, "2", "1984");
            Book book3 = addBook(db, "3", "The War of the Worlds");
            Book book4 = addBook(db, "4", "Brave New World");
            addBook(db, "5", "Foundation");
            try {
                // Loans are added with a delay, to have time for the UI to react to changes.

                Date today = getTodayPlusDays(0);
                Date yesterday = getTodayPlusDays(-1);
                Date twoDaysAgo = getTodayPlusDays(-2);
                Date lastWeek = getTodayPlusDays(-7);
                Date twoWeeksAgo = getTodayPlusDays(-14);

                addLoan(db, user1, book1, twoWeeksAgo, lastWeek);
                Thread.sleep(DELAY_MILLIS);
                addLoan(db, user2, book1, lastWeek, yesterday);
                Thread.sleep(DELAY_MILLIS);
                addLoan(db, user2, book2, lastWeek, today);
                Thread.sleep(DELAY_MILLIS);
                addLoan(db, user2, book3, lastWeek, twoDaysAgo);
                Thread.sleep(DELAY_MILLIS);
                addLoan(db, user2, book4, lastWeek, today);
                Log.d("DB", "Added loans");

            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    };

    private static Date getTodayPlusDays(int daysAgo) {
        Calendar calendar = Calendar.getInstance();
        calendar.set(Calendar.DATE, daysAgo);
        return calendar.getTime();
    }

    private static void checkpoint(Realm db) {
        db.commitTransaction();
        db.beginTransaction();
    }
   
    private static void addLoan(final Realm db, final User user, final Book book, Date from, Date to) {
        loanModel(db).addLoan(from, to, user.getId(), book.getId());
        checkpoint(db);
    }

    private static Book addBook(final Realm db, final String id, final String title) {
        Book book = bookModel(db).createOrUpdate(new Book(id, title));
        checkpoint(db);
        return book;
    }

    private static User addUser(final Realm db, final String id, final String name,
                                final String lastName, final int age) {
        User user = userModel(db).createOrUpdate(new User(id, name, lastName, age));
        checkpoint(db);
        return user;
    }

このブログで私が作ったアプリを実際に試してみたい方は、こちらからダウンロードすることができます。

最後に

以前よりもAndroidアプリ開発を簡潔に行えるようにするためのガイドやフレームワークが現れたことにとても興奮しています。Kotlinに対する公式のサポートや今回のArchitecture Components(そして SQLiteよりも高速でリアクティブな代替手段 😉)など、かつてないほどAndroidアプリ開発者にとって良い時代がやって来たことを感じます!

関連ニュース Realm JavaでAndroidアプリのデータ永続化が素早く安全に実装できるようになります。

General link arrow white

About the content

This content has been published here with the express permission of the author.


Eric Maxwell

EricはRealmのブロダクトエンジニアです。彼は10年以上の間、ヘルスケア、保険、図書館学、航空などの様々な産業においてソフトウエアの設計と開発に関わって来ました。現在はエンジニアの育成とモバイルアプリ開発にフォーカスしていて、Java、Android、iOSなどの教育コースを開発し実際に教えてもいます。プライベートでは家族との時間、旅行、即興コメディをエンジョイしています。

4 design patterns for a RESTless mobile integration »

close