はじめに
この内容は『iOSアプリ開発のためのFunctional Architecture情報共有会4』のための資料です。
最初に結論
私自身はpointfree自体のやり方を真似していくのが良いと思います。ただ、色々と試してみたという感じです。
結論は
- 何がベストなのかはチームにあってるやり方を採用したらいい
- pointfree自体のやり方でClientをつくるってのもそれがチームに合っていればそれでいい
導入
The Composable Architecture (TCA)では副作用実行にグローバルな関数1やClient、またはManagerというのが使われる。公式サンプルだけでなくpointfreeのゲームアプリのコードでもClientがよく出てくる。これを普段よく使われるUseCase/Repository/DataSourceを使いたいというのが発表の主旨。
TCAの副作用実行のための関数やClientについてのおさらい
(グローバルな)関数?
サンプルではグローバルな関数を使っていた。
次のコードはWeb API呼び出しで任意の数字からその数字にまつわるトリビアを返す非同期な処理。
func liveNumberFact(for n: Int) -> Effect<String, NumbersApiError> {
return URLSession.shared.dataTaskPublisher(for: URL(string: "http://numbersapi.com/\(n)/trivia")!)
.map { data, _ in String(decoding: data, as: UTF8.self) }
.catch { _ in
// Sometimes numbersapi.com can be flakey, so if it ever fails we will just
// default to a mock response.
Just("\(n) is a good number Brent")
.delay(for: 1, scheduler: DispatchQueue.main)
}
.setFailureType(to: NumbersApiError.self)
.eraseToEffect()
}
関数利用側でEnvironmentによってDIする場合。Swiftにおいても、関数も変数として扱えるのでDIするときは(Parameter) -> Effect
クロージャさえ合えばいい。
struct EffectsBasicsEnvironment {
var numberFact: (Int) -> Effect<String, NumbersApiError>
}
これで結果を置き換えられるのでXcodeプレビューでも楽だし、テストコードも楽に書ける。
ただ、グローバルな関数として用意するのは複数人でアプリを作るときはあまりやりたくない。サンプルだから良いけども。
Client/Managerとは?
公式のサンプルではClient/Managerは次の通り。
- Client系
- WebSocketClient
- SpeechClient
- DownloadClient
- AudioPlayerClient
- WeatherClient
- AudioRecorderClient
- Manager系
- LocationManager(これはサンプルというよりTCA公式のLocationラッパーかも)
- MotionManager(これはサンプルというよりTCA公式のMotionラッパーかも)
ちなみにClientってどんなものなんだろう?
Clientにはvar
クロージャが処理を返しロジックが入れ替えられるようになってる。
struct WeatherClient {
var searchLocation: (String) -> Effect<[Location], Failure>
var weather: (Int) -> Effect<LocationWeather, Failure>
struct Failure: Error, Equatable {}
}
struct SearchEnvironment {
var weatherClient: WeatherClient
var mainQueue: AnySchedulerOf<DispatchQueue>
}
クロージャを変数で入れる場合にEnvironmentに直接入れるのではなく、型としてまとめている。
プロダクション用の実処理自体はextensionに.live
定数で用意
extension WeatherClient {
static let live = WeatherClient( // 初期化してクロージャに処理を用意してそれを定数 .live として返す
searchLocation: { query in
var components = URLComponents(string: "https://www.metaweather.com/api/location/search")!
components.queryItems = [URLQueryItem(name: "query", value: query)]
return URLSession.shared.dataTaskPublisher(for: components.url!) // URLSessionをDIしてない(する気がない
.map { data, _ in data }
.decode(type: [Location].self, decoder: jsonDecoder)
.mapError { _ in Failure() }
.eraseToEffect()
},
...
短所
- 処理がstaticな定数だから変数を参照することはできない
- URLSessionをDIすることができない
- WeatherClient自体を置き換えやすいが、WeatherClient自体をテストする気がない
- 例えばDBで特定の要素が上書きされるロジックのテストとかはやる気がない
- WeatherClient自体を置き換えやすいが、WeatherClient自体をテストする気がない
- URLSessionをDIすることができない
おそらくReducerの仕様さえテストできてれば良くて、そこから奥にあるミスっていうのはReducerの仕様に関するテストコードで気付こうという感じなのではないか。
短所を解決するには?
呼び出し時に引数で置き換えたいものを渡す
// Reducer内
case let .locationTapped(location):
struct SearchWeatherId: Hashable {}
state.locationWeatherRequestInFlight = location
return environment.weatherClient
.weather(location.id, /* ここでURLSessionを渡す */)
もしくは定数.live
のなかでFactory.getURLSession()
みたいなのを用意してプリプロセッサ的なマクロで切り替えて取り出してもいい(が、その仕組みを自動化せず作るのは自分はあんまり...)。
ここまでのまとめ
- pointfreeの人たちは細かな副作用の単体テストをReducerのテストでカバーしてる
- 副作用での細かな処理のテストをやるには引数で渡すとか、Factory的なのを内部で切り替える
おまけ: 最近公開されたpointfreeのゲームのコードもClient
- DictionaryClient
- FeedbackGeneratorClient
- FileClient
- LocalDatabaseClient
- LowPowerModeClient
- RemoteNotificationsClient
- ServerConfigClient
- UIApplicationClient
- UserDefaultsClient
Managerはない。
UseCase/Repository/DataSourceで副作用を呼び出す
Google I/O 2019 - 2021 Android Appから
層の解説
- ドメイン層(いわくlightweight domain layer)
- データレイヤーとプレゼンテーションレイヤーの間に位置
- UIスレッドから離れた場所でビジネスロジックの個別部分を処理
- UseCaseクラスを中心に構成されてる
- コールバック地獄を避けるために、LiveData を使用してユースケースの結果を公開
- 2020あたりではコルーチンも使われはじめる
- 2018あたりまではexecuteNowメソッドで同期的なものは直接返したり
- そもそもAndroid Blue Printという設計のお手本リポジトリがあった
- データレイヤーとプレゼンテーションレイヤーの間に位置
- データ層
- リポジトリモジュールは、すべてのデータ操作を処理し、アプリの他の部分からデータソースを抽象化する役割を担う
データ層についての引用
私たちはFirestoreを気に入って使用していましたが、将来的に別のデータソースに交換したくなった場合、このアーキテクチャによってすっきりとした方法で交換することができます)
Google I/O 2019 - 2021 Android AppにおけるUseCase実装
...
/**
* A [UseCase] that returns the [UserSession]s for a user.
*/
class LoadUserSessionOneShotUseCase @Inject constructor(
private val userEventRepository: DefaultSessionAndUserEventRepository,
@IoDispatcher dispatcher: CoroutineDispatcher
) : UseCase<Pair<String, SessionId>, UserSession>(dispatcher) {
// suspendさせてコルーチンなメソッドだが
override suspend fun execute(parameters: Pair<String, SessionId>): UserSession {
val (userId, eventId) = parameters
// 実処理は非同期ではなく同期処理
return userEventRepository.getUserSession(userId, eventId)
}
}
- セッション情報を取得するUseCase
- コルーチンで結果
UserSession
を返してる - 処理は
userEventRepository
のgetUserSession
メソッド- 同期も非同期もインタフェース揃えるため
suspend fun
でコルーチンだけど、同期的に処理2
- 同期も非同期もインタフェース揃えるため
- コルーチンで結果
SwiftでUseCaseを作る場合を考える
- 同期と非同期二つにUseCaseを分ける(まだコルーチンないし)
- Swiftではassociated typeを持つprotocolを作る
- これを解決する型消去のやり方は複数ある
同期処理するSyncUseCaseの例
public protocol SyncUseCase where Failure: Error {
associatedtype Parameters
associatedtype Success
associatedtype Failure
func perform(_ parameters: Parameters) -> Result<Success, Failure>
}
このSyncUseCaseを直接利用する型でそのまま準拠してもそのまま使うことはできないので型消去するがその話は別に書いてる。
ProfileをSaveするUseCaseを例に
public class SaveProfileUseCase: SyncUseCase {
public typealias Parameters = Profile
public typealias Success = ()
public typealias Failure = Error
private let dataSource: ProfileLocalDataSource
init(dataSource: ProfileLocalDataSource) {
self.dataSource = dataSource // initでdataSourceを入れ替えられるように
}
public func perform(_ parameters: Parameters) -> Result<Success, Failure> {
dataSource.save(Profiles: parameters) // 実処理はdataSourceのCore Dataとかでやってる
}
}
import ComposableArchitecture
extension SaveProfileListUseCase {
static func executeEffect(Profile: Parameters) -> Effect<Success, Failure> {
.result { perform(profile) } // Effectにして結果を返す
}
}
UseCase型の長所と短所
- 長所
- initでDataSourceを入れるので実行時に引数で用意するわけじゃない
- 従来のプログラミングスタイルと同じ感じでできる
- UseCase自体のテストコードを書ける(依存してるDataSourceを置き換えられるし)
- initでDataSourceを入れるので実行時に引数で用意するわけじゃない
- 短所
- 関数型のやり方に従来のオブジェクト指向型のプログラミングスタイルが混ざることの心理的抵抗感
- UseCase型がめちゃくちゃ増える
UseCaseは使わないがRepository/DataSourceを使う
UseCase型を使わずに、しかしReducer以外の副作用の単体テストしたいのでRepository/DataSourceは使う場合を考える。
// プロフィールを変更して上書きする的なState, Action Environmentの集まり
enum ProfileSettingCore {
enum State { ... }
struct Action { ... }
struct Environment {
// DIできるようにする
var save: (Profile, ProfileDataSource) -> Result<(), Error>
}
static let reducer = ...
}
ProfileをSaveする例
// プロフィールを変更して上書きする的なState, Action Environmentの集まり
enum ProfileSettingCore {
enum State { ... }
struct Action { ... }
struct Environment {
// DIできるようにする
var save: (Profile, ProfileDataSource) -> Result<(), Error>
}
static let reducer = ...
}
extension ProfileSettingCore {
enum SideEffect {
static func save(profile: Profile, dataSource: ProfileDataSource) -> Result<(), Error> {
dataSource.update(profile) // 実質的なUseCaseの処理
}
}
}
UseCaseを使わないSideEffectでグルーピングされたstatic関数の長所と短所
- 長所
- シンプル(UseCase型の面倒な型消去なんて考えなくていい)
- SideEffect自体のテストコードを書ける(依存してるDataSourceを置き換えられるし)
- 短所
- 副作用実行の引数で依存するRepository/DataSourceを入れるのはかったるい
まとめ
- 何がベストなのかはチームにあってるやり方を採用したらいい
- pointfree自体のやり方でClientをつくるってのもそれがチームに合っていればそれでいい