"Guide to app architecture" 読む
内容が新しくなって大幅増量されてたので読む。
解釈や感想のメモ。
Overview その1
Mobile app user experiences
ここは旧版と変わらず。 app components (Activity
, Fragment
, Service
, ContentProvider
, BroadcastReceiver
) は個々が OS によって独立して起動されたり停止されたりすることがあるし、それはアプリの都合を考慮しないから互いに依存するような作りにはしないようにしましょうという話。
Common architectural principles
関心の分離とデータの話。Activity や Fragment 上には OS との interaction や UI 操作以外の実装は乗せないようにしましょう、UI (や他の app component) はその lifecycle から独立した data store で保持される data model によって表現される状態を反映する、つまり UI の中に状態を持たせないようにしましょうということ。
Overview その2
Recommended app architecture
内容が新しくなってる。まずは関心ごとに応じた2つ (3つ) の層について:
- UI layer: データの表示と外部との interaction を担当する
- Data layer: データの表現とビジネスロジックを担当する
- (Optional) Domain layer: UI layer と data layer の interaction (ビジネスロジック部分だろうか) を切り出して使い回すような必要があれば間にこの層を持ってもいいよ
ViewModel (Jetpack ViewModel のこと) にはデータを集約して UI state を更新する以上のロジックは極力持たないようにしましょうという指針。まあそうしないと ViewModel はすぐに fat になるからね...
Data layer では repository pattern を採用することを前提としている。この辺は結局旧版の View-ViewModel-Repository-DataSource の構造を layer という形で一段抽象度を上げて説明しているだけとも言える。この辺は multi-module 化も考慮した図解に再構成したという趣がある。
Domain layer については、 Data layer から取得したデータを集約するところで込み入ったロジックを実装する必要があったり、複雑でなくとも repository の上に表出してしまうようなロジックが発生した場合は UI layer (= ViewModel) に置かずに間に層を一つ挟んでそこに置きましょうというような感じ。 ViewModel を絶対 fat にしたくないという意志がある。他に考えられる方針としては data layer のビジネスロジック部分は pure Kotlin な実装で済ませられるものを置いて、 domain layer には Android SDK に依存するものを置く、みたいなのもありかも。 Data source まわりの実装で Android SDK 関連の依存が必要になるだろうけどビジネスロジック部分とは module 分けてしまえばいいかなー。
Manage dependencies between components
「Dagger Hilt 使ってね! (宣伝)」以上。
General best practices
そうだねって感じの内容。
UI layer その1
UI layer の責務の一つとして「data layer から取れたアプリの state をうつし出す」というものがあるが、 data layer からやってきたデータは大抵 UI で表現したい形そのもの、というわけではないので、中身の取捨選択や加工、複数 data source から取ってきて集約するみたいな UI 向けの変換も UI layer の役目だよ、と冒頭に書かれている。この雰囲気だと data layer と UI layer の2箇所で別々の data model を作る必要がありそう?
Regardless of the logic you apply, you need to pass the UI all the information it needs to render fully. The UI layer is the pipeline that converts application data changes to a form that the UI can present and then displays it.
A basic case study
以後の解説は Jetnews を具体例として進めるよ。
UI layer architecture
- Consume app data and transform it into data the UI can easily render.
やっぱ UI layer でも別途 data model を用意する想定で良さそうだ。
UI layer その2
Define UI state
UI layer での data model は "UI state" のことっぽい。 Immutable にするのはそれはそう。
Manage state with Unidirectional Data Flow
State holder は Jetpack ViewModel に限らず、その規模や役割に応じて適したものを用意しましょうみたいな話がされている。この辺はその holder の生存期間も関わってくるかな。
UDF について、ここでは
- UI -> state holder は user event を送るだけ
- state holder -> UI は UI state (の更新) を通知するだけ
という関係を押さえておけばよい。 UDF は構造ではなくデータの流れの設計に関する指針で、アプリケーションの各 component 間だったり全体だったりのデータの流れを制限してシンプルにしましょうという話。そうするとそれぞれの component の役割や関係が単純になって、処理や状態の変化を追いやすくなったりメンテナンス性の向上に寄与したりするメリットがあるよ、という理解をしている。
State に対する変更ではなく UI の表示に関するロジックを "UI (behavior) logic" として、これは Android SDK への依存が必要なので Jetpack ViewModel を通さない形で UI layer に置いてね、UI elements が大きく複雑になってきたらそれ用の state holder を作って仕事を委譲してもいいよ、とのこと。やっぱり生存期間の話が出てきた。
If the UI grows in complexity and you want to delegate the UI logic to another class to favor testability and separation of concerns, you can create a simple class as a state holder. Simple classes created in the UI can take Android SDK dependencies because they follow the lifecycle of the UI; ViewModel objects have a longer lifespan.
UI logic 関連の state holder の実装は UI state のそれとは性質が異なりそうに感じる。具体例は Jetpack Compose State guide を読むと良いそう、まだ読んでない。
UI layer その3
Expose UI state
StateFlow
や LiveData
(Jetpack Compose だったら State
) を使って state holder に consumer の顔が見えないようにしましょう, UI は自分で state を取りに行かないで口開けて待ってるだけで更新された state が放り込まれるようにしましょう、みたいな話っぽい。
UI state の更新がひたすら copy()
なのはよくやる実装だけど、エラーの状態については message だけで表現するんだなあ、UI 側でどうするかは後で解説するみたいなのでそっちに任せる。
関連するもの、つまり一緒に使うことのあるものは同じ state object にまとめましょう、はい。非同期で取得するようにな作りでも取ってきたものだけ更新できるようにするために state object は data class にしておこう。
この辺の話は Mavericks の MavericksState
の section でも同じような話をしてたね:
基本的に1画面に1つの UI state の構成がいいけど、データの流れや更新する対象の UI が全く独立してて無関係といえるような要素は別々の stream にしてしまって良い、こういうのがぜんぶまとまってると更新の必要のない箇所にも毎度更新通知が飛んできて無駄な UI 更新処理が動いてしまうから、とのこと。データの設計をしっかりしていれば例えば RecyclerView では ListAdapter
を使って差分更新ができるみたいなのはある。あと Jetpack Compose を使ってると Compose 側で差分更新してくれるのでこの辺の考慮はいらなくなるかも。
Consume UI state
State holder によって公開された stream を購読して、流れてきたデータを表示するだけの実装にしたいという話。 Kotlin flow を使っている場合も AndroidX Lifecycle 2.4.0 から repeatOnLifecycle
が生えてきたお陰で困らなくなった。
In-progress と error の表示について。それ用の property を UI state に生やしてそこだけ見て (例では map
してる) diff があった時だけ処理すればいいんじゃない? という風。 In-progress or not は boolean で十分だけど error は何かしらユーザへのフィードバックが必要なことが殆どなので、そういう時は専用データを定義してそれ (の変更) の有無みたいな実装になるかも? error 処理については引っ張った割にふわっとした記述なので悩ましいポイントになるかもしれない。 UI 側でどのように伝えるか次第なところもあるので具体的な guide は出せなかった?
UI layer その4
Threading and concurrency
Jetpack ViewModel で行われる処理は main thread が起点になることを保証しよう。こんな感じの話は以前聞いたことがある。
Navigation
UI events と絡みそうな話してる。Navigation component 使ってほしさがある。
Paging
Paging library を使う時の話。
Animation
所謂 animation じゃなくて画面間 (ここでは Fragment 間) の transition をスムーズに表現したい時に transition を遅延することができるよって話。
UI events その1
Guide で扱われる用語について:
- UI: ここでは実装コードを言及する時に使う
- UI events: UI layer で処理する event, 責任範囲の視点で event に言及する時にこれで呼ばれそう
- User events: ユーザがアプリを操作したことによって発生した event, 発生源について言及する時に使われそう
"UI events" と呼ぶだけではそれが UI logic か business logic のどちらで処理されるかまでは区別しない。単純に UI elements 上で発生した/受け取った event という認識でよさそう。
UI events を処理するものとして business logic と UI behavior logic について再度言及があるが、ここではそれらをどのように捉えるとよいか一つの指針を与えている。つまり、 business logic は同じサービス・プロダクトであればどのような形態のアプリケーションでも同じものとなるが、 UI behavior logic はプラットフォーム等それぞれで共有できない実装の詳細であると書かれている。この辺は具体例で考えると KMM など cross-platform な実装でそれぞれのプラットフォーム向けのアプリで共有できる部分、それぞれ向けに実装しなければならない部分の境界を決める時に役立つかもしれない。
UI event decision tree
簡単な tree で端的に表現されてるけど、つまりどういうこと? という点について:
- ViewModel を起点にする event?
- User event ではない event で、かつ UI state に影響を与えるもの
- Context を要求されるようなものでなければ ViewModel に置いてしまおうとも取れる
- UI elements を起点にする event で UI behavior logic で処理させるものは ViewModel を通さずに UI elements (+ UI logic 用の state holder) 上で処理してしまう
- 何でも一旦 ViewModel を通す、ということをする必要はない
UI events その2
Handle user events
RecyclerView
のように action (user event とだいたい同義) を発生させる UI element が app components (ここでは Activity か Fragment) から離れたところにある構造の UI で ViewModel に event を送る必要がある場合、 UI state object に event を処理する関数を property として持たせて対象の UI element に渡すようにすると ViewModel への直接依存をせずに処理をつなぐことができるよとある。
data class NewsItemUiState(
val title: String,
val body: String,
val bookmarked: Boolean = false,
val publicationDate: String,
val onBookmark: () -> Unit
)
class LatestNewsViewModel(
private val formatDateUseCase: FormatDateUseCase,
private val repository: NewsRepository
)
val newsListUiItems = repository.latestNews.map { news ->
NewsItemUiState(
title = news.title,
body = news.body,
bookmarked = news.bookmarked,
publicationDate = formatDateUseCase(news.publicationDate),
// Business logic is passed as a lambda function that the
// UI calls on click events.
onBookmark = {
repository.addBookmark(news.id)
}
)
}
}
(上記コードは https://developer.android.com/jetpack/guide/ui-layer/events#recyclerview-events より引用)
ここで実現したいのは ViewModel への依存は Activity か Fragment だけにしたい (RecyclerView
の adapter や view holder に ViewModel 自体を渡すなとも書かれている) ということで、上記のようにすることで view は送られてきた UI state の値を当て込むだけで event 処理の流れを構築できる。
この方法については確かに view 側の実装がシンプルになるし依存をうまく切り離せているが、一方で、例えば例示されたコードのようにリスト形式のデータを持つ UI state の場合、その item 1個1個に関数オブジェクトを持たせるのか? みたいな漠然とした懸念を持たないわけでもない。まあ素朴なもので、拡張関数などでは inlining して関数オブジェクトの生成を抑えようみたいな活動してるのにここでは安易に量産してしまうの? みたいな。
別にやることが同じなら、よくやるやつだと思うのだけど、その UI element に callback 関数が渡せるようにして、それを通じて ViewModel の関数を呼ぶようにするという方法もある (これは guide でも Note で触れている)。この場合 UI state は action のために必要なデータを持っていてそれを view に流し込み、 callback 関数に渡す引数としてその値を用いるという実装になる。 このようにすると callback 関数の生成は Activity で一度行われるだけになるので item ごとに関数を作るよりは経済的になるはず。こちらの実装にも微妙なポイントはあって、 Activity から action を生成する UI element まで距離が遠い場合、その経路 (関数) 全ての引数に callback 関数が追加される、つまりはバケツリレー状態になることだ。
この2つの実装パターンでどちらを選ぶかについては、対象となる UI や UI state の構造をもとに判断することになりそう。
UI events その3
Handle ViewModel events
Event 処理で Jetpack ViewModel が絡んだら必ず UI state を更新する形で反映しなよという話。そのデータの生存させたい期間によっては saved state も使いなよというのは以下の記事を参照するとよい:
ここから先は event 処理と UI state の考え方について。
Your thought process shouldn't end with determining how to make the UI navigate to a particular screen, for example. You need to think further and consider how to represent that user flow in your UI state. In other words: don't think about what actions the UI needs to make; think about how those actions affect the UI state.
ここはまあそういう考え方なのだなあという受け取り方でもいいかもしれない。つまり event を event のまま扱うのではなくて、それ (の結果) を UI state で表現しましょうと言っている。でも navigation とか message の表示につながるような UI state の保持って configuration change の度に発火しちゃうのでは、という疑問についてはそういう状況では "event の consume" という state の更新を実装しましょうという考え方のようだ ("Consuming events can trigger state updates")。
Kotlin coroutine の Channel
とか SharedFlow
とかあるのに consume まで自分で実装するのかよーという感じだが、この guide ではそういうのが必要になったら何か変だから考え直せってスタンスっぽい (最後の Note を参照)。考え直せというのは ViewModel から UI elements に伝わるのは UI state だけになるように、その event (1回だけ利用されてほしいデータ) は UI の状態に対してどのように作用するのか、それは UI state としてどう表現できるか、という視点でデータの流れを構築しましょうという話のよう。
絶対この考え方に則って設計しないといけないというわけではないので、このやり方だと無理が出そうな部分があったらそれぞれ上手く組めそうな方法を考えるで良いと思う。とはいえ UI events と UI state を結びつけて考えるという方針は UI の設計や実装がとっ散らかるのを防いでくれそうに感じるので、まずはこれベースで組み立てていくのが堅実という印象。
でも意外と 「UI state はできるだけ1つの data class としてまとめておきたい」というところから1回きりのデータもそこにまとめられて、そうしたら SharedFlow
使えないじゃん、じゃあ consume を自前で実装しないといけないねみたいな理由だったりして。
Other use cases
前半2項目は section の title の割にこのページのまとめといった内容。後半2つは event の consumer は1人だけにしようという話と consume する期間を意識しようという話。
Data layer その1
Repository pattern: https://martinfowler.com/eaaCatalog/repository.html
すごく大雑把にまとめると、上層の窓口として repository を用意してそこで data sources を束ねる仕事をさせるという感じ。
Note で repository も data source も1つしかなかったら1つの repository としてまとめられるけど〜という話があるが、テストの事とかを考えると data source を mock できるように分けておいた方が結局楽という気もする。
Expose APIs
Data layer, というか repository が公開する API をどうするか、 Kotlin で coroutine 使ってるなら one-shot な操作はただの suspend function でいいよというの、まあ coroutine になったしな、って思った。
- One-shot: コンテンツの詳細情報みたいに1回で全部取ってくる取得処理や、投げっぱなしの更新処理、とか
- Notification と stream: アプリ内で複数回発生させられる action で、その結果を都度 (UI state に) 反映させる必要のあるもの、とか
Naming conventions in this guide
Data source はデータの在処で分けましょうというのはそうだねという程度なのだけど、その粒度は大雑把 (remote of local 程度) にしておきましょうというのはなるほどという感じ。 Local (disk) 側は DB と File くらいまでなら分けても良いかも?
Interface で抽象化の話は層分けてるのでそういう事ももちろん考慮に入ってるねって話。
Source of truth
Single source of truth の話かと思ったけどこういう話だったっけ?
複数の data source を扱っている場合はどれ (DB? in-memory cache? remote data source?) が source of truth なのか1つに決めておきましょう、 repository 単位では別々で構わないよとある。この書き方だとそれぞれの repository で扱うデータは排他になっているべき (複数の repository で同じ data type を扱わない) という前提があるようだし、だから repository は多層化して良いという話があるのだろうね、という所感。
Data layer その2
Lifecycle
Data layer に属する class の instance の生存期間について。アプリ全体で使われるようなら application scope で、特定の画面やシナリオでしか使わないならその期間分に相当する scope で、みたいに設定すると良くて、そういうのは Hilt で設定できるよ! という話。
Scoping のための annotation 一覧は↓:
Types of data operations
基本的には "Common tasks" の前段として data layer が扱う仕事を継続期間で3種類に分けて定義している:
- UI-oriented: 使ってる画面が終了するまで (だいたい Jetpack ViewModel の生存期間に準ずる)
- App-oriented: アプリの process が終了するまで
- Business-oriented: 仕事が終わるまで (cancel されない)
Expose errors
素朴に考えればエラーは exception で表現すればいいよねってなるけど、 business logic におけるエラーはつまるところ想定内のシナリオに含まれるのでそれを他の (普遍的な?) exception と一緒くたに扱っていいのだろうか? というところが気になってくると, Result<T>
とか場合によっては自分で Result<T, E>
みたいなのとエラーを表現する object 群を用意して, throw されるのはより本来的な意味での「想定外」だけにする方がいいんじゃないかという気持ちにもなる。他にも coroutine の cancel が CancellationException
として流れてくるのもエラーシナリオを exception で表現したくないなという気持ちにさせる。 Exception 何でもありすぎて使いにくくない? 知らんけど。
この section の Note ではその選択肢 (Result<T>
を使ったエラーの表現) について言及されていたので個人的にちょっと嬉しかった。
Data layer その3
Common tasks
in-memory caching のところのメモ:
- 2つの coroutine scope がある
-
NewsRepository
の scope (=externalScope
) -
suspend fun getLatestNews
を呼ぶ scope
-
- Data source を呼んで cache するところまでは
externalScope.async
の suspended lambda なので 2. の scope で cancel されても 1. の scope で cancel されない限り実行が継続する -
async
の返却値に対するawait()
は 2. の scope での suspend なので、中断中に 2. で cancel されると再開されない - 結果としてデータ取得中に 2. の scope を持っている画面が終了した時、2. の scope の coroutine は cancel されて
getLatestNews
のawait()
は処理を完遂させずに終わるけど,externalScope.async
に与えた lambda の中身は最後まで実行される
WorkManager の利用について、例示されているコードでは RefreshLatestNewsWorker
に NewsRepository
が inject されているが、これを行うにはデフォルトの auto-initialization を止めて on-demand initialization の実装を行う必要があるので注意。DI に Hilt を使っているなら androidx.hilt:hilt-work
を使うのがよさそう。
Domain layer
Overview のメモ でも少し触れたことだけど、domain layer の役割として書かれている "UI layer の複数箇所で使われる共通の logic" というのは明確には書かれていないけどやはり "データに対する Android 的な加工" なんだろうなあというのが何となく読み取れる。つまり、 UI state に与えるアプリのデータを作るのに Android SDK に依存する処理が必要な場合、それを行うのは data layer (= repository) 内ではなく domain layer に分離するということ。こういうことをしたい動機としてはそのまま business logic 実装における Android SDK への依存の局所化で、例えば local test における Robolectric 利用箇所を減らせるみたいなメリットがある。
Lifecycle
Domain layer に置くのは状態 (というか mutable な要素) を持たないほとんど関数みたいな class にするべきなので scope は利用者に応じる形で良いとのこと。 Hilt でいうと scoping annotation つけなくて良いということ。
Threading
どっちかというと別 thread に移して実行する logic を domain layer と data layer どちらに置くかの評価軸の話。結果を cache して使い回す必要があるなら data layer に置くようにし、毎回新しい値を作るようなものなら domain layer でも良い、という感じ。
Common tasks
複数データの集約も domain layer の役割だけど、 source of truth が DB の時とか、あと remote data source で GraphQL 使ってるとかでもっと下層で集約が難なく行える場合は data layer でデータを作る時点で集約してしまって良い。 repository の名前はそれとわかるようにしよう、とのこと。