前の記事はsupabaseのブランチングを試してみた

データ層深掘り

参考: 該当の公式ドキュメント

まずはお馴染みの図

レポジトリクラス

書いてある役割はこんな感じ

  • 呼び出された時に、データを公開する
  • 呼び出される時に、データソースのことを意識せずに呼び出せるようにする
  • データの変更を一元管理する
  • データソース間の競合を解決する
  • ビジネスロジックを格納する

解釈するとdata sourceとやり取りするためのインターフェースとしての役割と、ビジネスロジックを格納する役割が存在する。

インターフェースとしての役割

データの取得や保存、変更等の処理をカプセル化してあげることで、内部の処理のことを考えずにデータをやり取りできるようになる。 data sourceの構成が変わったときに、その変更が他のレイヤーに影響を与えないようにするために、変更を吸収する

ビジネスロジック格納場所としての役割

これは単体だとわかりやすいが、viewModelのことを考えると少し複雑になる。 どういうロジックをviewmodelにおいて、どういうロジックをrepositoryにおけば良いのだろうか?

少し調べてみると、公式ドキュメント内に以下の記載を見つけた

アプリのビジネスロジックの大部分を含みます

基本的にはビジネスロジックはrepositoryに含むということがわかった。 ここで一つの仮説が立てられる。 基本はrepositoryにおいて、見た目関連のロジックがあるならviewModelにおくというものだ。

直接的な記事を見つけることができなかったので、公式が出しているアーキテクチャサンプルのレポジトリから該当部分を見つけて自分で解釈してみる。

まず適当に見つけたRepositoryがDefaultTaskRepository 次に適当に見つけたViewModelがTasksViewModel

これを見るとビジネスロジックは全部repositoryに入っているように見える。

  • ビジネスロジック→repository
  • ビジネスロジック以外のロジック→viewModel という分け方でよさそう

ビジネスロジック以外のロジックととは、例えば 表示するもののフィルタリングなど、ユーザーの操作を扱う時の処理を言う。

違う言い方をすれば、画面の表示にしか関連しないロジックはviewModelに入れておけばよさそう。 (これは自分の解釈なので違う意見があれば教えてください。)

データソースクラス

ファイルやネットワーク経由のDB、ローカルのDBなどのデータ本体が入っている部分とアプリの橋渡し。

データソースを呼び出せるのは、レポジトリクラスだけで、他のレイヤからは絶対に呼び出さない。

また呼び出す時にも、直接依存させるようにはせずに、DIを使って依存関係を注入するようにする。

データモデル

これはドキュメントには書いてないが絶対必要。

このコードラボの例がわかりやすい。 ローカルとネットワーク経由のデータソースを両方もつ、タスク管理アプリで考える。

data class Task(
    val id: String
    val title: String = "",
    val description: String = "",
    val isCompleted: Boolean = false,
) { ... }

データレイヤ外部でも使うものと、内部でのみ使うものを定義しておくのが良いだろう。 先ほどの例で考えると、外部に公開するTaskクラスだけでなく、ローカルのデータソースで指標するLocalTaskと、ネットワークのデータソースで使用するNetworktaskの二つを作るのが良さそう。

data class LocalTask(
    @PrimaryKey val id: String,
    var title: String,
    var description: String,
    var isCompleted: Boolean,
)
data class NetworkTask(
    val id: String,
    val title: String,
    val shortDescription: String,
    val priority: Int? = null,
    val status: TaskStatus = TaskStatus.ACTIVE
) {
    enum class TaskStatus {
        ACTIVE,
        COMPLETE
    }

}

そして上記のように、データクラスを分けた場合は、Mappingするための拡張関数も定義する必要がある。

Codelabだと別のファイルを作成して、そこの中にマッピング用の拡張関数をまとめていた。

例:ModelMappingExt.kt

~~~
fun Task.toLocal() = LocalTask(
    id = id,
    title = title,
    description = description,
    isCompleted = isCompleted,
)
~~~

データレイヤ全体で気をつけること

他のレイヤーに対してデータを公開するときは、immutable、つまり不変の形にしなくてはならない。

データレイヤーから公開されたデータがどこかで勝手に変更されてしまうと、値の一元性が保てずに意図しないバグが発生する可能性がある。

実際の実装イメージ

ここからは実際に実装する時のイメージを説明する。 ただし、javaは自分の経験がないので、kotlinの場合しか説明しない。 javaの場合は適宜読み替えてね。

ワンショットオペレーションの場合

ワンショットオペレーションとは、一度呼び出したらその後は値の変更を監視しなくてもよいオペレーションのこと。 例えばプロフィール詳細画面を表示した時に、最初に一度だけユーザー情報を取得してその後はその値をもとに処理を行えば良いときなんてのがそうだろう。

ワンショットオペレーションでの約束は一つだけ。 suspend関数を利用してUIの処理を止めないようにする必要がある。 例えばこんな形で書くべきと言うことだろう

class UserRepository(private val apiService: ApiService) {
    // suspend関数を使って、非同期でデータを取得
    suspend fun fetchUser(userId: String): User {
        return apiService.getUser(userId) // データソースの処理
    }

普通の関数と違うのは、suspend関数はCoroutine Scope内で呼び出すという点だ。 要は、UIを操作するのとは別の場所で処理をするので、画面表示の処理を妨害せずに並行して処理をしてくれるものくらいの認識で大丈夫。

ていうか想定読者層的には、この説明いらないなぁ、、、

リアルタイムなデータ変更を監視する場合

上記のワンショットと違い、データの変更をリアルタイムに反映する必要があるときは少しだけ複雑になる。 Stream型のデータを扱えるようにしなくてはいけないからだ。

正しkotlinの場合はflowという仕組みがある。 flowを使えば、簡単にデータを操作できる。詳細な説明は長くなってしまうので割愛

例としてはこんな感じ

class ExampleRepository(
    private val exampleRemoteDataSource: ExampleRemoteDataSource, // network
    private val exampleLocalDataSource: ExampleLocalDataSource // database
) {

    val data: Flow<Example> = ...

    suspend fun modifyData(example: Example) { ... }
}

命名規則

基本的にはこのルールに従っておこう、みんなの共通認識としてのルールがあるだけで、コードが読みやすくなる。

レポジトリ

データの種類 + Repository

例: NewsRepository, MoviesRepository

データソース

データの種類 + ソースの種類 + DataSource

ソースの種類というのはRemoteやLocalなどか、もっと具体的にNetworkやDiskなどでも良い。

例: NewsRemoteDataSource, NewsLocalDataSource

注意しないといけないのは、データの保存方法などを意識した命名にしないことです。(例:UserSharedPreferencesDataSourceはだめ) 内部実装と結びついた命名にしてしまうと、内部実装を変えた時に名前を変える必要性が出てきてしまいます。

注意事項

外部に公開するデータは信頼できる唯一の情報源である必要がある

複数のデータソースからデータを取得する場合やデータソースには保存しない場合でも、他のレイヤーに公開するときは一つの信頼できる情報源として提供する必要がある。

オフラインファーストのアプリを意識するのはおすすめはローカルデータソース。

スレッド化

データソースやレポジトリの呼び出しは、メインスレッドから安全に呼び出せなくてはならない。 RoomやRetrofitを使う場合にはそれらが提供するAPIはsuspend関数であることがほとんどなので、それらをちゃんと使えば良い。

ライフサイクル

データレイヤーのクラスの、インスタンスのスコープ(どの範囲でそのインスタンスを保持し、再利用するか)を適切に設計して、メモリ効率を保とうという話。

二つの要素がある。

  1. インスタンスを再利用しよう キャッシュや一時データを持つクラスは、毎回インスタンスを生成せずにできるだけ同じインスタンスを再利用しよう

  2. スコープの設定をしよう アプリケーション全体でしようするならApplicationスコープで、特定の画面やフロー内で利用するならActivityや特定のフローのスコープを設定しよう。

実際にスコープを管理する時には、HiltやDaggerなどを利用することが多いはず。

自分の思う使い分けとしては以下のような感じで考えている(Hiltなら)

アプリ全体のデータ→@Singleton

特定の画面やフローでしか使わない→@ActivityRetainedScopedかナビゲーションスコープ

一時的な処理→関数内でインスタンス生成

外部に公開するデータクラス(モデル)は切り分けよう

APIからのレスポンスを画面に表示する場合を考えるとわかりやすいが、データソースから提供される情報とアプリが必要としている情報が違う場合が多い。 そういう時にはあえて別のデータクラスを作って、アプリに必要な情報だけを取り出すようにしよう。

例 APIサーバーから提供される情報

data class ArticleApiModel(
    val id: Long,
    val title: String,
    val content: String,
    val publicationDate: Date,
    val modifications: Array<ArticleApiModel>,
    val comments: Array<CommentApiModel>,
    val lastModificationDate: Date,
    val authorId: Long,
    val authorName: String,
    val authorDateOfBirth: Date,
    val readTimeMin: Int
)

アプリの画面に必要な情報

data class Article(
    val id: Long,
    val title: String,
    val content: String,
    val publicationDate: Date,
    val authorName: String,
    val readTimeMin: Int
)

これをしておくことで、以下のメリットを得られる

  • アプリのメモリを節約できる
  • 外部のデータ型がアプリに必要なデータ型と違った場合でも対応しやすい(例: 日付の形式)
  • 関心の分離ができる(アプリでデータを表示するときに、データソースのことを気にしなくてよくなる)

処理の分類

データレイヤーが行う処理は、重要性とライフサイクルによって三つに分類できる。

  1. UI指向

    ユーザーが特定の画面にいる間だけ実行されるもの。

    UIレイヤーによってトリガーされて、ライフサイクルも呼び出し元と同じになる。

    例)チャット画面で新着メッセージを取得する

  2. アプリ指向

    アプリが開いている間だけ実行されるもの。

    アプリが終了されるかプロセスが強制終了されるまでは存続する

    ライフサイクルはApplicationクラスと同じか、データレイヤの状態によって決められる

    例)ユーザーのログイン状態

  3. ビジネス指向

    必ず実行されるべき重要なもの。

    基本的にキャンセルされることはなくプロセス終了後も存続する。

    WorkManagerを使ってスケジュール設定を行うことが推奨されている。

    例)振り込み手続き、写真のアップロード

次の記事はGiven When Thenとは何なのか?