AndroidのRoomライブラリを用いたDB非同期処理をHandlerで書いてみた

この記事はOPTiM TECH BLOG Advent Calendar 2020 12/16 の記事です。

はじめまして!Optimal Bizチームの片岡です。
20新卒として今年の4月に入社し、日々Androidの開発やQA業務を行っています。

2020年の9月にAndroid 11が発表され、対応するAPIレベル30でAsyncTaskが非推奨になりました。弊社プロダクトの非同期処理にもよく使われているため、「AsyncTask無しで非同期処理を書くにはどうしたらいいのだろう」と思ったのが本記事のきっかけです。
そこで今回は、AndroidのJetpackライブラリであるRoomを用いたDB非同期処理について書いてみました。使用言語はJavaです。

サンプルアプリのリポジトリはこちらです。

Roomライブラリとは

Roomライブラリとは、AndroidでのDB操作をより簡単により安全に行うために利用できるライブラリで、2020年12月現在の安定版リリースは2.2.5となります。 以下に、公式のデベロッパーガイドの説明を引用します。

Room は、SQLite 全体を対象とする抽象化レイヤを提供し、SQLite を最大限に活用しつつ、スムーズなデータベース アクセスを可能にします。

Room を使用してローカル データベースにデータを保存する  |  Android デベロッパー  |  Android Developers より

Roomを構成するコンポーネント

Roomは大きく3つのコンポーネントから構成されます。

Database

アプリケーションで扱う永続化データへのアクセスポイントとなるクラスです。 @Databaseアノテーションを付け、RoomDatabaseクラスを継承する必要があります。
関連するEntityをアノテーション内で、Daoをクラス内で記述することで、他コンポーネントとの連携が可能となります。

実装例を以下に示します。

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

    /**
     * DB操作に使用するDAOの抽象メソッド。
     *
     * @return UserDaoのオブジェクト。
     */
    public abstract UserDao UserDao();

    private static UserRoomDatabase userRoomDatabase;

    /**
     * Roomデータベースを返す。存在しなければ、作成する。
     *
     * @param context
     * @return UserRoomDatabase
     */
    public static UserRoomDatabase getDatabase(final Context context) {
        if (userRoomDatabase == null) {
            synchronized (UserRoomDatabase.class) {
                if (userRoomDatabase == null) {
                    //DBを作成する。
                    userRoomDatabase = Room.databaseBuilder(context.getApplicationContext(),
                            UserRoomDatabase.class, "user_database")
                            .build();
                }
            }
        }
        return userRoomDatabase;
    }
}
Entity

データベース内のテーブルを表すクラスです。 @Entityアノテーションを付け、フィールドの数に関わらず主キーを宣言する必要があります。
主キーの宣言は、対応するフィールドに@PrimaryKeyアノテーションを付けるか、@EntityアノテーションのprimaryKeysプロパティを利用することで可能となります。

実装例を以下に示します。

@Entity
public class User {
    @PrimaryKey(autoGenerate = true)
    @ColumnInfo(name = "user_id")
    public int id;

    public String name;  //ユーザーの名前
    @ColumnInfo(name = "created_at")
    public Long createdAt; //ユーザーの登録時刻
}
Dao

データベースへのアクセスに使用するメソッドを格納するクラスです。 @Daoアノテーションを付ける必要があり、インターフェースとしても抽象クラスとしても利用することができます。
標準でCRUDのうちCreate, Update, Deleteを実現するコンビニエンスメソッドが用意されており、これらを利用することで簡単にDB操作を行うことができます。
また、Queryメソッドも用意されており、シンプルな読み込みクエリやその他複雑なクエリも書くことができます。
このQueryメソッドを用いて記述したクエリに構文エラーや存在しないテーブルへの参照がある場合、アプリのコンパイル時にコンパイルエラーを吐いてくれます。安心ですね。

実装例を以下に示します。

@Dao
public interface UserDao {

    @Query("SELECT user.* FROM user")
    public List<User> loadUsers();

    @Insert
    public void insertUser(User user);

    @Update
    public void updateUser(User user);

    @Delete
    public void deleteUser(User user);

    /**
     * Userを全て削除する。
     */
    @Query("DELETE FROM user")
    public void deleteAllUser();
}

DB非同期処理を書いてみる。

RoomではUIスレッド上でのデータベースアクセスが禁止されているため、何も考えずにDaoで宣言したメソッドを叩くとIllegalStateExceptionを吐かれてしまいます。
残念ながらAndroid 11(APIレベル30)からAsyncTaskが非推奨になってしまったので、今回はHandlerを使って実装してみました。

Handler, Looper, MessageQueueの関係

非同期処理を実現するためには、実際にはHandlerとLooper、そして(勿論)Threadを使う必要があります。また、そのためにMessageQueueを理解する必要があります。
まずはそれぞれの用語の関係を解説していきます。

MessageQueue

UIスレッドには(自他含む)スレッドからのMessageオブジェクトやRunnableオブジェクトを受け取るMessageQueueがあります。
Messageとはスレッド間通信をする際にデータを運ぶ役割を持つオブジェクトで、Runnable*1とはスレッドで実行する処理を記述したものです。

今回は、UIスレッドのMessageQueueにDB読み込み結果をMessageとして送ることで、取得したデータの利用を可能にします。
また、WorkerスレッドでのDB読み込み処理をRunnableオブジェクトで実行します。

Looper

MessageQueueへのMessage, Runnableの追加をキャッチするのがLooperで、投げられるオブジェクトをキャッチするためにグルグルMessageQueueを監視し続けています。
Looperはオブジェクトの追加をキャッチすると、UIスレッドに紐づくHandlerにそのオブジェクトを渡します。

Handler

HandlerはLooperから渡されたMessageを用いて処理を行います。
また、WorkerスレッドにHandlerオブジェクトを渡すことで、Workerスレッドでの処理結果をUIスレッドのMessageQueueに渡すこともできます。

実装

では、DB読み込み処理を実装していきましょう。

実装の大まかな流れは

  1. UIスレッドでHandlerオブジェクトを定義する。
  2. RunnableオブジェクトにHandlerオブジェクトを渡す。
  3. Workerスレッドで実行したDB読み込みの処理結果をMessageに載せて、UIスレッドに渡す。

となります。では、詳しく見ていきましょう。

1. UIスレッドでHandlerオブジェクトを定義する。

Workerスレッドでの非同期処理の結果をUIスレッドで受け取るためには、UIスレッド上でHandlerオブジェクトを生成する必要があります。
UIスレッド上でのHandlerオブジェクト生成は、生成時の引数にUIスレッドの持つLooperを渡すことで可能となります。

    @UiThread
    private void asyncRead() {
        Handler handler = new Handler(Looper.getMainLooper()) {
            //処理結果を受け取る。
            }
        };
    }
2. RunnableオブジェクトにHandlerオブジェクトを渡す。

次に、DB読み込み処理を実行するRunnableオブジェクトを作ります。今回は、Runnableを実装したBackgroundTaskReadクラスのオブジェクトが、WorkerスレッドのRunnableオブジェクトとなります。
このRunnableオブジェクトにHandlerオブジェクトを渡すことで、DB読み込み処理結果をUIスレッドに渡すことが可能となります。

    @UiThread
    private void asyncRead() {
        //省略~Handlerの生成~

        BackgroundTaskRead backgroundTaskRead = new BackgroundTaskRead(handler, userDao, allUser);
        //ワーカースレッドで実行する。
        executorService.submit(backgroundTaskRead);
    }

    /**
     * DB非同期処理を実行するRunnableオブジェクト
     */
    private static class BackgroundTaskRead implements Runnable {
        private final Handler handler;
        private UserDao userDao;
        private List<User> user;

        BackgroundTaskRead(Handler handler, UserDao userDao, List<User> user) {
            this.handler = handler;
            this.userDao = userDao;
            this.user = user;
        }

        @WorkerThread
        @Override
        public void run() {
            //Daoクラスで用意したDB読み込みメソッドを実行する。
            userDao.loadUsers();
        }
    }
3. Workerスレッドで実行したDB読み込みの処理結果をMessageに載せて、UIスレッドに渡す。

run()内でDB読み込みメソッドを実行するだけだと、UIスレッドが処理結果を受け取れないため、せっかく取得したデータを利用することができません。

UIスレッドへ処理結果を渡すには、処理結果を載せたMessageを用意し、Workerスレッドの持つHandlerオブジェクトからhandler.sendMessage()*2を実行すればOKです。
処理結果をMessageに載せるには、handler.obtainMessage()を利用します。この引数にDB読み込み結果を渡すことで、処理結果付きのMessageを作れます。
このhandler.obtainMessage()の返り値をsendMessage()に渡せば、晴れてUIスレッドへMessageを送ることが可能となります。

    /**
     * DB非同期処理を実行するRunnableオブジェクト
     */
    private static class BackgroundTaskRead implements Runnable {
        //省略~コンストラクタ~

        @WorkerThread
        @Override
        public void run() {
            //非同期処理を開始する。
            user = userDao.loadUsers();
            handler.sendMessage(handler.obtainMessage(READ, user));
        }
    }

あとは、UIスレッド側で送られたMessageを受け取ればいいですね。
sendMessage()により送られるMessageを受け取るために、Handlerの用意するhandleMessage()*3をOverrideします。 ここでMessageに付随するobject(今回はDB読み込み結果)をcallback*4に渡すことで、処理完了という適切なタイミングでViewの更新など次の動作に移ることができます。

    @UiThread
    private void asyncRead() {
        //ワーカースレッドからDB読み込み結果を受け取る。
        Handler handler = new Handler(Looper.getMainLooper()) {
            @Override
            public void handleMessage(@NonNull Message msg) {
                if (msg.obj != null) {
                    allUser = (List<User>) msg.obj;
                    //処理が完了したら、callbackに処理を返す。
                    callback.onReadUserCompleted(allUser);
                }
            }
        };
        BackgroundTaskRead backgroundTaskRead = new BackgroundTaskRead(handler, userDao, allUser);
        //ワーカースレッドで実行する。
        executorService.submit(backgroundTaskRead);
    }

最後に

今回は、Handlerを用いて非同期処理を書いてみました。
AsyncTaskが非推奨になってしまったことで「javaで非同期処理を書くにはどうしたらいいだろう」という悩みを持った、自分のような新米エンジニアの助けとなれば幸いです。

オプティムでは、Android開発をはじめ様々な分野に一緒に挑戦していけるエンジニアを募集しています。
興味を持っていただけた方は是非弊社採用ページをご覧ください!

www.optim.co.jp

*1:正確には、Runnableインターフェースを実装したオブジェクト

*2:sendMessage()メソッドは、Handlerが紐づくスレッドのMessageQueueにMessageを送ります。

*3:handleMessage()は、LooperがキャッチしたMessageを受け取るメソッドです。

*4:ここでのcallbackは独自に実装しているクラスのオブジェクトです。