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が終了されるまで生き続けます。
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
を使うことで、VideModel
がActivity
やFragment
の参照を保持することなしに画面更新を実現することができます。Activity側からLiveData
に関連づけを行うことで、データの変更を受け取ることができるようになります。
Googleがライブデータという考え方を取り入れたことはとてもすばらしい事です。また、これはRealmが持つ変更通知受け取り可能なライブデータという特性ともとてもよくマッチします。
Architecture Componentsが素晴らしいのはそのpluggableな設計です。どのようなデータであってもLiveData
として扱うことができます。どのようなコンポーネントでもライフサイクルと協調させることができ、アプリが必要とするViewModel
を作ることができます。今回公開されたガイドとツールは本当に素晴らしいです 🎉
今回のGoogle I/Oでのコードラボに含まれるandroid-persistenceを使って、Realmがこれらのコンポーネントとどのように組み合わさるかを見ていきましょう。コードラボで出て来るSQLiteのテーブル構造をRealmのデータモデルで置き換えてみます。置き換え前と後で以下のようになります。
最も大きな違いはクラス間の関連です。Realmでは、User
とBook
はLoan
(ここでのLoanは本の貸し出しを意味しています)のリストを持っていると同時に、Loan
は自身と関連づけられたUser
とBook
への参照を保持しています。それに対して、SQLiteではLoan
テーブル内の外部キーとして保持され、のちに出てきますがSQLで記述されるクエリの中で結合されます。
それではモデル定義をRealmを使ったものに更新して見ましょう。以下にLoan
モデルの変更前と変更後の例を示します。
クエリ
クエリについても書き換えます。例えばfindLoansByNameAfter()
ですが、インターフェースメソッドとそのアノテーションとしてSQL書く方法から、メソッド本体でRealmQueryを構築する方法に書き換えます。
それではこれらを組み合わせて全体としてどのようになるか見ていきましょう、
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
はコードラボ版のものととても似ていますが、一番大きな違いはLoan
とUser
それぞれにDAOを定義するのではなく、単に貸し出し(Loan)情報の参照のみを保持していて必要に応じてそこからUser
やBook
を参照している点です。これは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アプリ開発者にとって良い時代がやって来たことを感じます!
About the content
This content has been published here with the express permission of the author.