狛ログ

2018年2月26日月曜日

Andoroidの公式ドキュメントを読み解く Guide to App Architecture


オフィス狛 技術部です。

Android開発を行うにあたり、
Architecture Components という Google が推奨する設計の例があります。

公式ドキュメントは英語のものしか存在しないので、
自分なりの解釈を含めて読んでいきたいと思います。

アプリケーション設計のガイド

※このガイドの対象はAndroidフレームワークに対しての一定の知識がある方を対象にしています。

・アプリ開発でぶつかるよくある問題

単一のエントリーポイント、プロセスで動く多くの昔からあるデスクトップアプリとは異なり、Androidはアプリは複雑な構造をしています。

典型的なAndroidアプリではアクティビティ、フラグメント、サービスなどの
様々なコンポーネントで構成されています。
これらのコンポーネントはアプリマニフェストで宣言され、
それを基にOSがアプリからユーザーにどういう体験を与えるかを決めます。

Androidアプリはユーザーによって様々な使い方がされるため
常にフローやタスクの切り替えが行われてることなどを考慮するなどの柔軟性が求められます。

例えば、SNSで写真をシェアしたい場合、
カメラを起動するためにSNSアプリを離れます。
この際にファイルを選択したり、他のアプリを起動するかもしれません。
電話がかかってくるかもしれません。
最終的には写真をシェアするためにSNSアプリに戻ってきます。

Androidのアプリではこういったことはよくあることで、
この写真をシェアするという一連の流れは正確にスムーズに行われなければなりません。
さらに、モバイル端末はリソースに限りがあり、
OSがバックグラウンドにあるアプリを終了されることがある、ということも肝に命じておけなければなりません。

なので、アプリのメモリ上にデータや状態を保存しておくということは避けるべきです。

・共通の設計原則

一番大事なことは「責務の分離」です。
アクティビティやフラグメントに全てを書くのはよろしくありません。

UIやOSに関連するイベントの処理をアクティビティやフラグメントに書くことを避けることで多くのライフサイクル関連の問題を避けることができます。
直接クラスを所持するでのはなく、OSとアプリの間の契約(インターフェイス)で繋げるようにします。
OSが突然アプリが終了することを考慮して必要最低限の依存関係を持つように心がけます。

次に大事な原則は「UIの変化はモデル、特にデータを保存するモデルの変化を起点に引き起こす」ということです。
こうすることで、OSがアプリを終了させたり、ネットワークに接続できなくなった場合にも対応することができます。

モデルはアプリのデータを処理することに特化し、ビューや他のコンポーネントからは独立させることで、ライフサイクルの問題とは無関係になります。UIに関わるコードはシンプルにアプリのロジックとは引き離すことでコードの管理がよりやりやすくなります。

・おすすめのアプリ設計

以下はArchitecture Componentsを用いたアプリケーションの例を示します。
※全てのシナリオに適合できるベストな設計というものは存在しませんが、
これから示すものは多くのシナリオにどのような設計を適合するかを考える良い出発点になるでしょう。

ユーザーインターフェイスを構築する

UIはUserProfileFragment.javaとuser_profile_layout.xmlで構成します。

UIの変化を引き起こす2つの要素をモデルは保持します。

UserID : ユーザーを識別する値。フラグメントの引数を用いてフラグメントに値を渡すようにします。OSがアプリを終了させる際に保存するようにします。

User object : ユーザーデータを保持するためのPOJO

ViewModelをベースにしたUserProfileViewModelを作成します。
※ViewModelはアプリのデータ取得や変更を行う他のコンポーネントを呼び出し、
特定のフラグメントやアクティビティにデータを提供するクラスで、
Viewの内容は一切知らず、画面回転などの端末の変更に伴うフラグメントやアクティビティの再生成に影響されません。
public class UserProfileViewModel extends ViewModel {
    private String userId;
    private User user;

    public void init(String userId) {
        this.userId = userId;
    }
    public User getUser() {
        return user;
    }
}
public class UserProfileFragment extends Fragment {
    private static final String UID_KEY = "uid";
    private UserProfileViewModel viewModel;

    @Override
    public void onActivityCreated(@Nullable Bundle savedInstanceState) {
        super.onActivityCreated(savedInstanceState);
        String userId = getArguments().getString(UID_KEY);
        viewModel = ViewModelProviders.of(this).get(UserProfileViewModel.class);
        viewModel.init(userId);
    }

    @Override
    public View onCreateView(LayoutInflater inflater,
                @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
        return inflater.inflate(R.layout.user_profile, container, false);
    }
}

どうやってモデルとUIを繋げるか?ということに対してLiveDataを活用します。


※LiveDataは監視可能(Observable)なデータホルダーです。

LiveDataオブジェクトを監視することで明示的な依存関係の設定などをすることなく、
変化した際の通知を受け取ることができるようになります。また、アプリのコンポーネントのライフサイクルに連動するため、コンポーネントが破棄されると同時に破棄され、メモリーリークを引き起こすことがありません。また参照がなくなれば自動で破棄されます。

UserProfileModel.javaを変更します。
public class UserProfileViewModel extends ViewModel {
    ...
    private User user;
    private LiveData<User> user;
    public LiveData<User> getUser() {
        return user;
    }
}

UserProfileFragment.javaでLiveDataを監視するようにします。
@Override
public void onActivityCreated(@Nullable Bundle savedInstanceState) {
    super.onActivityCreated(savedInstanceState);
    viewModel.getUser().observe(this, user -> {
      // update UI
    });
}

データが更新されるたびにonChangedコールバックが呼ばれ、UIを更新することができます。
自動でライフサイクルと連動しているためonStop()で監視の破棄をする必要はありません。

ViewModelは画面回転などの端末の変更があった場合に、保存され、アクティビティやフラグメントが再生成された場合に同じインスタンスを提供し、再び監視された際は即座に現在の値を通知することができます。
この仕組みがあるため、ViewModelはViewを参照するべきではありません。


データ取得

次にViewModelがデータを取得する方法を実装します。
この例ではREST APIからデータを取得することを前提にRetrofitライブラリを用いるとします。
public interface Webservice {
    /**
     * @GET declares an HTTP GET request
     * @Path("user") annotation on the userId parameter marks it as a
     * replacement for the {user} placeholder in the @GET path
     */
    @GET("/users/{user}")
    Call<User> getUser(@Path("user") String userId);
}

ViewModelから直接Webserviceに接続することも可能ですが、
データ取得の責務までViewModelに持たせると「責務分離の原則」に違反します。
また、アクティビティやフラグメントのライフサイクルとViewModelと連動しているため、ViewModelがすべてのデータを保持するとライフサイクルと同時にデータが失われてしまうことになります。

そこで、Repositoryを用います。

※Repositoryはデータ操作の責務を担い、クリーンなAPIを提供します。
どこからデータを取得するべきか、データが更新されたときにRepositoryのどのAPIを呼ぶかを知っています。
Repositoryは他のデータソース(永続的なモデル、Webservice、キャッシュなど)とアプリの仲介役(いわゆるメディエーター)だと見なせます。
public class UserRepository {
    private Webservice webservice;
    // ...
    public LiveData<User> getUser(int userId) {
        // これは良い方法ではないので後に修正する
        final MutableLiveData<User> data = new MutableLiveData<>();
        webservice.getUser(userId).enqueue(new Callback<User>() {
            @Override
            public void onResponse(Call<User> call, Response<User> response) {
                // エラーケースは省略
                data.setValue(response.body());
            }
        });
        return data;
    }
}

一見Repositoryは必要ないように思われますが、
データを操作する方法を抽象化することで
ViewModelからWebserviceだけではなく他の手段からもデータを取得できるようになります。

UserRepositoryクラスはWebserviceインスタンスが必要になります。ということはWebserviceインスタンスを作成するための依存関係を知らなければなりません。
さらにRepositoryが増えれば増えるほど、Webserviceインスタンスが作成されることになります。

これを解消するために、2つパターンがあります。
Dependency Injection(DI) : コンストラクタを使用せずに依存関係を設定することができ、実装時に依存関係を注入してインスタンスの自動作成をしてくれます。Googleの推奨はDagger2です。

Service Locator : コンストラクタの代わりに依存関係を注入するクラスを登録するを提供します。もしDIに不慣れな場合はこちらの方が簡単なので代わりに使うとよいでしょう。

この例では、Dagger2を使います。


ViewModelとRepositoryを繋げる

UserProfileViewModelを変更します。
public class UserProfileViewModel extends ViewModel {
    private LiveData<User> user;
    private UserRepository userRepo;

    @Inject // Dagger2からインスタンスが提供される
    public UserProfileViewModel(UserRepository userRepo) {
        this.userRepo = userRepo;
    }

    public void init(String userId) {
        if (this.user != null) {
            // ViewModelはフラグメントごとに生成されるため
            // userIdが変わらないことを知っている
            return;
        }
        user = userRepo.getUser(userId);
    }

    public LiveData<User> getUser() {
        return this.user;
    }
}


キャッシュデータ

上記の実装ではWebserviceの呼び出しを抽象化できていますが、1つのデータソース(この場合はREST APIからのデータ)に依存しているため、あまり機能的ではありません。

問題点は、REST APIから取得したデータをどこにも保存していないことです。
例えば、UserProfileFragmentから離れた後再び戻ってきた場合、
UserProfileFragmentは再びREST APIからデータを取得しなければなりません。

ここに2つ問題があります。
不必要にネットワーク帯域を消費する。
データ取得が完了するまでにユーザーを待たせてしまう。

これを解決するためにUserRepository(メモリ)にデータをキャッシュします。
@Singleton  // Daggerにシングルトンであることを知らせる
public class UserRepository {
    private Webservice webservice;
    // メモリーキャッシュ
    private UserCache userCache;
    public LiveData<User> getUser(String userId) {
        LiveData<User> cached = userCache.get(userId);
        if (cached != null) {
            return cached;
        }

        final MutableLiveData<User> data = new MutableLiveData<>();
        userCache.put(userId, data);
        // まだ改善が可能だか前のバージョンよりは良い
        // 取得後の中でエラーチェックが必要
        webservice.getUser(userId).enqueue(new Callback<User>() {
            @Override
            public void onResponse(Call<User> call, Response<User> response) {
                data.setValue(response.body());
            }
        });
        return data;
    }
}


永続的なモデルへの保存

今までの実装ですと、画面が回転したり、アプリがバックグラウンドに移動しても
キャッシュからデータを取得することができますが、
OSがアプリを終了された後はどうでしょうか?再びREST APIからデータを取得しなければなりません。

サーバー側でWebリクエスト自体をキャッシュするとどうでしょうか?
1つのリクエストでは問題が起きませんが、
同じデータを複数のリクエストから取得するような場合はどうでしょうか?

あるリクエストでキャッシュにデータを保存した後、データが変更され、
他のリクエストから同じデータを取得した場合、
データの不整合が起き、ユーザーは混乱するかもしれません。

ここでは永続的なモデルへ保存することが適切です。
今回はRoomライブラリを用います。

※Roomは必要最小限のボイラープレートコードで
ローカル上にデータの永続化を提供するオブジェクトマッピングライブラリです。
間違ったSQLはコンパイル時にエラーとなり、生のSQL操作をラッピングしてくれます。またLiveDataを経由してデータの変更を通知することができます。さらに、メインスレッドでデータベースをアクセスするなどの問題を明示的に指摘してくれます。

Roomを使用するために、スキーマを定義します。
まずUserクラスに@Entityアノテーションを付加します。
@Entity
class User {
  @PrimaryKey
  private int id;
  private String name;
  private String lastName;
  // getters と setters
}
次にDatabaseクラスを定義します。
@Database(entities = {User.class}, version = 1)
public abstract class MyDatabase extends RoomDatabase {
}
MyDatabaseは抽象クラスで、Roomが自動で実装を提供してくれます。

自動実装以外のメソッドを提供するために、DAOクラスを定義します。
@Dao
public interface UserDao {
    @Insert(onConflict = REPLACE)
    void save(User user);
    @Query("SELECT * FROM user WHERE id = :userId")
    LiveData<User> load(String userId);
}

DatabaseクラスからDAOクラスを参照します。
@Database(entities = {User.class}, version = 1)
public abstract class MyDatabase extends RoomDatabase {
    public abstract UserDao userDao();
}

ここで注目したいのはDAOのloadメソッドでLiveDataを返していることです。
LiveDataを返すことでDatabaseに変化があった際、このLiveDataを監視しているオブジェクトに自動で変更を通知します。
※Roomはテーブルの変更をチェックしています。

UserRepositoryとRoomデータソースを繋げます。
@Singleton
public class UserRepository {
    private final Webservice webservice;
    private final UserDao userDao;
    private final Executor executor;

    @Inject
    public UserRepository(Webservice webservice, UserDao userDao, Executor executor) {
        this.webservice = webservice;
        this.userDao = userDao;
        this.executor = executor;
    }

    public LiveData<User> getUser(String userId) {
        refreshUser(userId);
        // LiveDataをデータベースから返す
        return userDao.load(userId);
    }

    private void refreshUser(final String userId) {
        executor.execute(() -> {
            // バックグランドで実行
            // 直近でデータが取得されたかをチェック
            boolean userExists = userDao.hasUser(FRESH_TIMEOUT);
            if (!userExists) {
                // データを再取得
                Response response = webservice.getUser(userId).execute();
                // TODO エラーチェック
                // データベースを更新すると、自動的にLiveDataは通知をする
                // そのためデータベースの更新以外に何もする必要はない
                userDao.save(response.body());
            }
        });
    }
}

これで数日後にユーザーが同じ画面に戻ってきたときにもすぐに画面を表示できます。
一方で、もしデータが古ければ、バックグラウンドでRepositoryがデータを更新します。
場合によっては古すぎるデータは表示しないということもできます。

他のユースケースとして、
ユーザーがプルリフレッシュを行った際などは
ユーザーにデータを取得中であることを提示することが大切です。

この際、UIアクションと実際のデータは分けて考えた方が良いでしょう。
というのも、データは色々な理由で更新されることがありますが、
UIから見ると全てただの別々のデータが来ているだけであり、
更新される理由とは全く関係ないからです。

このケースには2つのよくある解決法があります。

〇getUserの戻り値のLiveDataに通信実行状態を含める。

〇Repositoryクラスに通信状態を返す機能を追加する。意図的にユーザーがデータを更新しようとしているときのみUIを変更したい場合などはこっちの方が良いでしょう。


Single source of truth(ただ1つのソース)

様々なREST APIが同じデータを返すことはよくあることです。
この際にRepositoryがAPIから取得したデータをそのままUIに反映させるような設計の場合、不適切なデータを表示してしまう可能性があります。(あるリクエスト取得中に別のリクエストでデータが変更されてしまっているなど)

だからこそRepository内のWebserviceのコールバックではデータベースの更新のみを行い、LiveDataの変更通知でUIの更新を行うようにするべきです。


テスト

「責務分離の原則」のメリットとしてテストのしやすさがあります。
それぞれのクラスのテストの方法を見ていきましょう。

〇ユーザーインターフェイス
UIテストをするにはEspressoテストを作成するのが良いです。フラグメントを作成し、ViewModelのモックを提供します。今回の設計ではフラグメントはViewModelのみ繋がっているためViewModelのモックでUIテストを全て網羅することができます。

○ViewModel
Junitを用います。UserRepositoryのモックを使用します。

〇UserRepository
Junitでテストします。WebserviceとDAOのモックを使用します。
正しいWebserviceが呼ばれているか、結果をデータベースに保存できているか、キャッシュデータがある場合はリクエストをしていないかを確認できます。

〇UserDao
Instrumentationテストを行います。UIは必要ないのでテスト実行時間を短くできます。各テストで他のテストの影響がないようにメモリ上のデータベースを使用します。
Roomの場合はSupportSQLiteOpenHelperのJunit実装を提供していますが、端末のバージョンとテストを実行しているマシンのSQLVersionが異なることがあるためあまりおすすめできません。

〇Webservice
外の世界とは独立してテストを実行することが大事です、ネットワーク通信を行うべきではありません。MockWebserviceなど偽のローカルサーバを提供してくれます。

〇Test Artifact
Architecture Componentsはバックエンドスレッドを制御するMavenアーティファクトを提供しています。android.arch.core:core-testingの中に2つのJUnitルールがあります。

-InstantTaskExecutorRule
Architecture Componentsにバックグラウンドスレッドのオペレーションを即時に実行するようにします。

-CountingTaskExecutorRule
Instrumentationテストの中でArchitecture Componentsのバックグランド実行を待機します。またはEspressoのidling resourceとして接続します。

最終的な設計は下記の図のようになります。

Architecture Components - Guide to App Architecture

・ガイドラインの原則

プログラミングは創造的な領域であり、問題の解決方法は数々あります。
下記のガイドラインは必須ではありませんが、より堅牢でメインテナンスしやすくテストしやすいコードを生成することができるでしょう。

○マニフェストに記載するエントリーポイント(アクティビティ、フラグメント、サービスブロードキャストレシーバーなど)はデータの発生元ではありません。
これらはエントリーポイントに関連するデータの一部を適切な場所に配置するだけにするべきです。
各コンポーネントは寿命が短く、端末に対するユーザーのアクションや、実行時のアプリの状態などに依ってしまうため、データの発生元にはしたくないでしょう。

○アプリのモジュール(コンポーネント)間の責務の境界ははっきりさせなさい。例えば、ネットワークからのデータ取得は複数のモジュールにまたがってはいけません。同様にデータのキャッシュやデータバインディングを同じクラスにしてはいけません。

○各モジュールから他のモジュールの機能を公開することは必要最小限にしなさい。1つのモジュールから内部の実装詳細を公開するような「これだけあればOK」みたいなショートカットに誘惑されてはいけません。

○モジュール間での作用を考える際、それぞれの独立したテストのしやすさについて考えなさい。例えばネットワークからデータを取得する洗練されたAPIはローカルデータベースへの保存テストをより簡単にします。

○アプリを他のアプリよりも際立たせることに集中しなさい。同じようなボイラープレートを何度も書いたり車輪の再発明をやめなさい。コアの部分はArchitecture Componentsやボイラープレートを解消してくれるライブラリに任せて、アプリを際立たせることに注力しなさい。

○オフラインでも可能な限り最新のデータを利用できるようにデータを保存しなさい。ユーザーのネットワークの状態が良くないかもしれません。

○Repositoryをだた1つのデータの発生元として設計すべきです。アプリがデータにアクセスする際はRepositoryから取得されるべきです。

・追記: ネットワークの状態を表示する

上記の例では可読性を上げるためにネットワークエラーとローディング表示を省略しました。下記でResourceクラスを利用した例を示します。

//ステータスとデータを保持するジェネリッククラス
public class Resource<T> {
    @NonNull public final Status status;
    @Nullable public final T data;
    @Nullable public final String message;
    private Resource(@NonNull Status status, @Nullable T data, @Nullable String message) {
        this.status = status;
        this.data = data;
        this.message = message;
    }

    public static <T> Resource<T> success(@NonNull T data) {
        return new Resource<>(SUCCESS, data, null);
    }

    public static <T> Resource<T> error(String msg, @Nullable T data) {
        return new Resource<>(ERROR, data, msg);
    }

    public static <T> Resource<T> loading(@Nullable T data) {
        return new Resource<>(LOADING, data, null);
    }
}


ディスクからデータを表示する間にネットワークからデータを取得することはよくあるため、ヘルパークラスとしてNetworkBoundResourceクラスを作成します。下記が処理の決定パターンです。



まず対象のリソースが保存してあるデータベースを監視することから始めます。
最初にデータベースからデータを取得した際、
NetworkBoundResourceクラスは結果が
ネットワークからデータを取得べきか、そのまま結果として出力すべきか判定します。
これはキャッシュからデータ取得する場合やデータが更新された場合の同様の手続きを踏みます。

ネットワークの呼び出しが成功した場合、レスポンスをデータベースに保存し、
ストリームを再生成します。失敗の場合は失敗の結果を出力します。

※ストリームの再生成は実際は必要ありません。しかし、データベース更新の副作用によるデータの通知のみに頼ることはあまり良いことではありません。例えば、データベースはデータの変更がない場合は通知を行いません。一方でAPIからの戻り値を直接出力することはデータの発生元が複数になってしまいます。また、データが取得できなかった場合に成功の結果を出力したくありませんでした。

下記がNetworkBoundResourceが提供する公開されたAPIです。
// ResultType: ローカルで使用する結果の型
// RequestType: APIレスポンスの型
public abstract class NetworkBoundResource<ResultType, RequestType> {
    // APIレスポンスをデータベースに保存する
    @WorkerThread
    protected abstract void saveCallResult(@NonNull RequestType item);

    // データベースからのデータから
    // ネットワークからのデータ取得が必要かを判定します。
    @MainThread
    protected abstract boolean shouldFetch(@Nullable ResultType data);

    // キャッシュデータを取得します。
    @NonNull @MainThread
    protected abstract LiveData<ResultType> loadFromDb();

    // APIを呼び出します。
    @NonNull @MainThread
    protected abstract LiveData<ApiResponse<RequestType>> createCall();

    // データ取得に失敗した際に呼ばれます。
    // 子クラスはコンポーネントのリクエスト上限のリセットなどをします
    @MainThread
    protected void onFetchFailed() {
    }

    // LiveDataを返します。
    // ベースクラスで実装されています。
    public final LiveData<Resource<ResultType>> getAsLiveData();
}

パラメータが2つ定義されていますが、
これはAPIの戻り値とローカルで仕様する結果の型が異なるためです。

また、ApiResponseという型が使用されていますが、これはRetrofit2.Callのシンプルなラッパーで、レスポンスをLiveDataに変換します。

下記はNetworkBoundResourceクラスの残りの実装です。
public abstract class NetworkBoundResource<ResultType, RequestType> {
    private final MediatorLiveData<Resource<ResultType>> result = new MediatorLiveData<>();

    @MainThread
    NetworkBoundResource() {
        result.setValue(Resource.loading(null));
        LiveData<ResultType> dbSource = loadFromDb();
        result.addSource(dbSource, data -> {
            result.removeSource(dbSource);
            if (shouldFetch(data)) {
                fetchFromNetwork(dbSource);
            } else {
                result.addSource(dbSource,
                        newData -> result.setValue(Resource.success(newData)));
            }
        });
    }

    private void fetchFromNetwork(final LiveData<ResultType> dbSource) {
        LiveData<ApiResponse<RequestType>> apiResponse = createCall();
        // 新しいリソースとして再アタッチします。
        // これによって最新のデータを即座に出力します。
        result.addSource(dbSource,
                newData -> result.setValue(Resource.loading(newData)));
        result.addSource(apiResponse, response -> {
            result.removeSource(apiResponse);
            result.removeSource(dbSource);
            // 最終判定
            if (response.isSuccessful()) {
                saveResultAndReInit(response);
            } else {
                onFetchFailed();
                result.addSource(dbSource,
                        newData -> result.setValue(
                                Resource.error(response.errorMessage, newData)));
            }
        });
    }

    @MainThread
    private void saveResultAndReInit(ApiResponse<RequestType> response) {
        new AsyncTask<Void, Void, Void>() {

            @Override
            protected Void doInBackground(Void... voids) {
                saveCallResult(response.body);
                return null;
            }

            @Override
            protected void onPostExecute(Void aVoid) {
                // 新しいLiveDataを要求する理由として
                // そうしなければ最新のキャッシュデータを取得してしまいます。
                // ネットワークから取得したデータでキャッシュデータは更新されないからです。                result.addSource(loadFromDb(),
                        newData -> result.setValue(Resource.success(newData)));
            }
        }.execute();
    }

    public final LiveData<Resource<ResultType>> getAsLiveData() {
        return result;
    }
}

最後にUserRepositoryを変更します。
class UserRepository {
    Webservice webservice;
    UserDao userDao;

    public LiveData<Resource<User>> loadUser(final String userId) {
        return new NetworkBoundResource<User,User>() {
            @Override
            protected void saveCallResult(@NonNull User item) {
                userDao.insert(item);
            }

            @Override
            protected boolean shouldFetch(@Nullable User data) {
                return rateLimiter.canFetch(userId) && (data == null || !isFresh(data));
            }

            @NonNull @Override
            protected LiveData<User> loadFromDb() {
                return userDao.load(userId);
            }

            @NonNull @Override
            protected LiveData<ApiResponse<User>> createCall() {
                return webservice.getUser(userId);
            }
        }.getAsLiveData();
    }
}


という訳で、まずは『Guide to App Architecture』を読み解いてみました。
読み込んでいくと、Googleの設計思想が分かった気がします。

また時間があれば、別の公式ドキュメントを読み解いて行こうと思います。

0 件のコメント:

コメントを投稿