この記事はレコチョク Advent Calendar 2022の24日目の記事となります。
はじめに
初めまして、永田です。
株式会社レコチョクでiOSアプリエンジニアとして働いています。
本日はクリスマスイブということでいよいよ年の瀬ですね。
今年の個人的ベストライブは「NUMBER GIRL 無常の日」、ベストアルバムは柴田聡子「ぼちぼち銀河」でした(柴田聡子さんかなりオススメです)。
さて、現在私が開発を担当しているPlayPASS Musicアプリではリアーキテクチャを推進しています。
その中でSwift 5.7の新機能を用いた設計を導入したので、そのご紹介をしようと思います。
開発環境
本記事で扱うコードは以下の環境で動くことを想定しています。
- Xcode 14.1
- Swift 5.7.1
- iOS 16.1
題材となるアプリの概要
PlayPASS Musicアプリでは、1つのアプリ上で
の4つの音楽配信サービスをサポートしています。それぞれのサービスに対して
- 購入した楽曲・アルバムを一覧画面で表示する
- 購入した楽曲を一覧画面でダウンロード(並びにキャンセル)する
- 購入したアルバムの詳細情報を詳細画面で表示する
- 購入したアルバムを詳細画面でダウンロード(並びにキャンセル)する
- 上記一覧・詳細画面から、ダウンロード済みの楽曲・アルバムを再生する
ことができます。
アプリの内部設計としてはVIPERをベースに、さらにPresentation, Domain, DataStoreとレイヤーごとにFrameworkを分割したものを採用していました。
旧アーキテクチャの課題
しかし、このようなアプリを開発し保守・運用していく中で以下のような課題が見えてきました。
- 責務の肥大化
- 実装のズレによるコードの見通しの悪化
- 似通ったドメインモデルの重複
これら3点について詳しく説明します。
責務の肥大化
リアーキテクチャ以前の旧アーキテクチャでは、UseCase, Repositoryをサービス単位で分けて、そのサービスに関連する処理を全てそこに集約していました。
しかしこの実装では1つのUseCase, Repositoryの持つ責務が大きくなりすぎてしまいました。その結果
- 上位のモジュールに対して不要なI/Fを見せすぎる
- テストが実装しづらい
- エンハンスやレビューがしづらくなる
などの問題が発生していました。
実装のズレによるコードの見通しの悪化
前述した通り、このアプリではそれぞれのサービスに対して
- 一覧表示機能
- 詳細取得機能
- DL・キャンセル機能
など同じような機能を提供しています。
しかし、UseCase, RepositoryのI/F設計に一貫したポリシーを設けていませんでした。
その結果、随所で微妙な実装差分が発生し、エンハンス・レビューがしづらくなっていました。
似通ったドメインモデルの重複
このアプリでは、DataStore層でAPI通信を行い、返却されたJSONをEntityと呼ばれるデータモデルにデコードしています。
そのEntityは、Domain層のTranslatorによってドメインモデル(Model)に変換されています。
旧アーキテクチャでは、楽曲やアルバムを表現するドメインモデルをサービスごとに定義していました。
しかし、それらのドメインモデルはEntityをほぼそのままマッピングしただけであり、データ構造がほぼ変わらないにも関わらず別のstruct
として存在していました。
これにより、似たような処理の実装でも扱うモデルの型が違うため、処理の共通化が難しくなってしまいます。
結果として、全サービスに対して共通したエンハンスを実行しようとした際に、単純計算で工数が4倍かかることになり、工数を圧迫する要因となっていました。
これらの問題から
- 肥大化した責務を適切に切り分ける
- 各サービスのI/F設計に縛りを設ける
- ドメインモデルを可能な限り共通化する
ことでエンハンス・修正・レビューにかかるコストを削減したいと考えました。
新しい設計の検討
そこで新しい設計についてチーム内で話し合い、このような設計にすることを検討しました。
変更点としては主に
- 1つのUseCase, Repositoryに集約されていた機能を3つに分割する
- 購入商品の一覧取得(PurchaseHistory)
- 購入商品の詳細取得(ProductDetail)
- 購入商品のDL・キャンセル(Download)
- 各サービスのUseCase, Repositoryの
protocol
を共通化する
の2点です。
イメージ図は以下の通りです。
これらを実現するにはどのような実装をすべきか検討していたところ、associatedtype
を活用したいと考えました。
しかし、ここでもさらに大きな壁にぶつかります。
それは「Swift 5.6以前ではassociatedtype
を持つprotocol
は実存型(Existential Type)として扱えない」という問題です。
当該protocol
をジェネリクスの型パラメータで指定する、型消去を用いるなどの方法でこの問題を回避することはできますが、別の部分で構文的な制約が生まれたり、難解なボイラープレートが増えるなど新たな問題も同時に生み出してしまいます。
この壁にぶつかり、protocol
の共通化を諦めるか…と妥協しかけていた頃、Xcode 14(Swift 5.7)がリリースされました。
このリリースでprotocol
関連の新機能がSwiftに追加され、(ほぼ)全ての問題を解決してくれました。
Swift 5.7から使えるprotocol
周りの新機能
今回紹介する内容に対応するプロポーザルはこちらです。
- swift-evolution/0309-unlock-existential-types-for-all-protocols.md at main · apple/swift-evolution
- swift-evolution/0346-light-weight-same-type-syntax.md at main · apple/swift-evolution
- swift-evolution/0353-constrained-existential-types.md at main · apple/swift-evolution
最初に、any
キーワードを用いることで、以下のようなassociatedtype
を持つ(もしくはSelf
を用いる)protocol
を実存型として扱うことができるようになります。
protocol HogeRepository {
associatedtype T
}
struct HogeGateway: HogeRepository {
typealias T = Int
}
struct HogeInteractor {
private let repository: any HogeRepository
init(repository: any HogeRepository) {
self.repository = repository
}
}
let useCase = HogeInteractor(repository: HogeGateway())
またprotocol
にもジェネリクスが使えるようになり、associatedtype
に対して使う側から制約を課すことができます。
protocol HogeRepository<T> {
associatedtype T
}
struct HogeGateway: HogeRepository {
typealias T = Int
}
struct HogeInteractor {
private let repository: any HogeRepository<Int>
init(repository: any HogeRepository<Int>) {
self.repository = repository
}
}
let useCase = HogeInteractor(repository: HogeGateway())
これらの機能を用いて、旧アーキテクチャの問題点の解消を試みました。
新アーキテクチャの導入
※ ここからご紹介するのは、実際に導入したコードに近いデモコードです。
まずは、商品の購入履歴を取得してくるPurchaseHistoryRepository
を定義します。
APIから購入履歴を取得する際に必要なパラメータやレスポンスの型がサービスごとに異なるため、それらもジェネリクスで指定できるようにしました。
このprotocol
に準拠したGatewayを実装することで、購入履歴の取得に必要なI/Fをサービス間で統一しつつ、各サービスで個別に必要な処理をGateway側で実装できます。
struct HogeServicePurchaseHistoryResponse {
/* 中略 */
}
struct FugaServicePurchaseHistoryResponse {
/* 中略 */
}
protocol PurchaseHistoryRepository<T, U> {
associatedtype T
associatedtype U
func fetch(
_ input: T,
completion: @escaping (Result<U, Error>) -> Void
)
}
enum PurchaseHistoryRepositoryInputs {
struct HogeService {
let offset: Int
let limit: Int
}
struct FugaService {
let offset: Int
let limit: Int
let token: String
}
}
typealias HogePurchaseHistoryRepository = PurchaseHistoryRepository<
PurchaseHistoryRepositoryInputs.HogeService,
HogeServicePurchaseHistoryResponse
>
typealias FugaPurchaseHistoryRepository = PurchaseHistoryRepository<
PurchaseHistoryRepositoryInputs.FugaService,
FugaServicePurchaseHistoryResponse
>
struct HogePurchaseHistoryGateway: PurchaseHistoryRepository {
func fetch(
_ input: PurchaseHistoryRepositoryInputs.HogeService,
completion: @escaping (Result<HogeServicePurchaseHistoryResponse, Error>) -> Void
) {
// 購入履歴を取得する
}
}
次に、サービスごとに散らばっていたドメインモデルを、ジェネリクスを用いて集約しました。
楽曲やアルバムの購入履歴を表現するために、
- 楽曲(
Single
) - アルバム(
Album
)
を定義しました。
PurchaseHistoryRepository
から返却されるレスポンスをこのstruct
に変換します。
ジェネリクスでは、それぞれのサービスにおいてAPI通信等で必要になる固有の値を集めたstruct
を指定します。
さらに、それらをassociated valueに持つPurchaseHistory
を定義しました。
これにより、楽曲・アルバムの混在した購入履歴を[PurchaseHistory]
として表現することができます。
enum PurchaseHistory<T, U> {
case single(Single<T, U>)
case album(Album<U>)
}
struct Single<T, U> {
let trackName: String
let artistName: String
let album: Album<T>?
/* 中略 */
/// 各サービスのAPI通信等で用いるサービス固有のパラメータ
let singleParameter: U
}
struct Album<T> {
let albumName: String
let albumArtistName: String
let artworkURL: URL?
/* 中略 */
/// 各サービスのAPI通信等で用いるサービス固有のパラメータ
let albumParameter: T
}
enum SingleParameters {
struct HogeService {
let storeID: Int
}
struct FugaService {
let purchaseID: Int
let rightID: Int
}
}
enum AlbumParameters {
struct HogeService {
let storeID: Int
}
struct FugaService {
let purchaseID: Int
let rightID: Int
}
}
typealias HogePurchaseHistory = PurchaseHistory<
SingleParameters.HogeService,
AlbumParameters.HogeService
>
typealias FugaPurchaseHistory = PurchaseHistory<
SingleParameters.FugaService,
AlbumParameters.FugaService
>
typealias HogeSingle = Single<SingleParameters.HogeService, AlbumParameters.HogeService>
typealias FugaSingle = Single<SingleParameters.FugaService, AlbumParameters.FugaService>
ここから、PurchaseHistoryRepository
を用いて商品の購入履歴を取得するPurchaseHistoryUseCase
を定義します。
先ほど定義したSingle
やAlbum
, PurchaseHistory
を用いて全サービス共通のprotocol
として使えるようにしました。
Repositoryと同様に、それぞれのサービスで購入履歴の取得時に必要なパラメータをジェネリクスで指定できるようにしました。
protocol PurchaseHistoryUseCase<T, U, V> {
associatedtype T
associatedtype U
associatedtype V
func fetch(
_ input: T,
completion: @escaping (Result<[PurchaseHistory<U, V>], Error>) -> Void
)
}
enum PurchaseHistoryUseCaseInputs {
struct HogeService {
let offset: Int
let limit: Int
}
struct FugaService {
let offset: Int
let limit: Int
let token: String
}
}
typealias HogePurchaseHistoryUseCase = PurchaseHistoryUseCase<
PurchaseHistoryUseCaseInputs.HogeService,
SingleParameters.HogeService,
AlbumParameters.HogeService
>
typealias FugaPurchaseHistoryUseCase = PurchaseHistoryUseCase<
PurchaseHistoryUseCaseInputs.FugaService,
SingleParameters.FugaService,
AlbumParameters.FugaService
>
struct HogePurchaseHistoryInteractor: PurchaseHistoryUseCase {
private let repository: any HogePurchaseHistoryRepository
init(repository: any HogePurchaseHistoryRepository) {
self.repository = repository
}
private func translate(_ response: HogeServicePurchaseHistoryResponse) -> [HogePurchaseHistory] {
// 変換処理を行う
[]
}
func fetch(
_ input: PurchaseHistoryUseCaseInputs.HogeService,
completion: @escaping (Result<[HogePurchaseHistory], Error>) -> Void
) {
repository.fetch(.init(
offset: input.offset,
limit: input.limit
)) {
switch $0 {
case let .success(response):
let purchaseHistories = translate(response)
completion(.success(purchaseHistories))
case let .failure(error):
completion(.failure(error))
}
}
}
}
このような設計にすることで、Presenterからは以下のようにUseCaseを呼び出すことができます。
final class HogePurchaseHistoryPresenter {
private let useCase: any HogePurchaseHistoryUseCase
/* 中略 */
private var purchaseHistories = [HogePurchaseHistory]()
init(useCase: any HogePurchaseHistoryUseCase) {
self.useCase = useCase
}
func viewDidLoad() {
useCase.fetch(.init(offset: 0, limit: 30)) { [weak self] in
guard let self else {
return
}
switch $0 {
case let .success(purchaseHistories):
self.purchaseHistories = purchaseHistories
// 一覧取得後のリロード処理
case let .failure(error):
print(error.localizedDescription)
// エラーハンドリング
}
}
}
}
let repository = HogePurchaseHistoryGateway()
let useCase = HogePurchaseHistoryInteractor(repository: repository)
let presenter = HogePurchaseHistoryPresenter(useCase: useCase)
このような、protocol
とジェネリクスの組み合わせによる設計を商品詳細取得(ProductDetail), DL・キャンセル(Download)のUseCase, Repositoryに対しても導入することで、以下の図のようにI/Fの設計に縛りを設けることができました。
また機能単位でUseCase, Repositoryを分割したことで、肥大化したUseCase, Repositoryを解体でき、再利用性を高めることができました。
これでめでたく
- 肥大化した責務を適切に切り分ける
- 各サービスのI/F設計に縛りを設ける
- ドメインモデルを可能な限り共通化する
という目的を全て達成できました。
新アーキテクチャの問題点
しかしながら、この新アーキテクチャも完璧ではありません。
大きく2点ほど問題が浮上しました。
学習コストが高い
まずはこのアーキテクチャを理解し、それに沿った実装ができるようになるまでのハードルが高いという問題があります。実際このアーキテクチャを導入する際には、レビュアに対してかなり手厚めにフォローを入れました。
原因としては、高度に抽象化されているが故の理解の難しさや、まだこの言語機能が実装されてから日が浅く、関連する技術記事がそれほど多くないことなどが挙げられるのではないかと考えています。
前者については、チームメンバーに対してSlackのハドルや対面のMTG等で説明をしたり、ジェネリクスの型パラメータ名を意味のある命名にすることで対応できるかと思います。
実際に、このアーキテクチャを練ってから実装に移るまでの間にチームメンバーに対して事前に対面で説明する場を設けました。
これにより、レビュアが設計の意図や概要を理解した状態でPull Requestのレビューに取り掛かることができました。
また、説明用のコードではジェネリクスの型パラメータをT
, U
, V
などの命名にしていますが、実際のコードではどのような型が指定されるのかがよりわかりやすい命名で実装しています。
特定の最適化条件・OSバージョンでクラッシュする
今回紹介したSwift 5.7の新機能ですが、Xcode 14.1の段階では、特定の最適化条件でビルドするとiOS 16未満の端末でクラッシュするという問題があります。
- Runtime crash "outlined init with take of any ..." · Issue #61403 · apple/swift
- Swift 5.7 crash in iOS 15 or lower with -Osize optimisation level. · Issue #60743 · apple/swift
主にリリースビルドで用いる特定の最適化条件下で発生するので、開発時に最適化条件を変更してビルドしたアプリを確認してみるのが良いかと思います。私はTestFlightでインストールしていたアプリを触って初めてこの事象に気付きました。審査提出直前だったのでかなり焦りました…
この問題を解消するPull Requestが既にマージされているので、近いうちにその修正がリリースされるのではないかと思われますが、現段階では当該protocol
をAnyObject
に準拠させることで回避できます。本来struct
で定義したいものもclass
で定義しなくてはいけなくなるので、意図しない挙動が起きないかは注意が必要です。
終わりに
今回は、Swift 5.7から導入されたprotocol
関連の新機能をプロダクトに導入した実例をご紹介しました。
設計が難解になる懸念はあるものの、従来の型消去のようなテクニックを使わないと実現できなかった実装が簡潔に実装できる強力な機能です。実プロダクトに導入するケースはそう多くないかもしれませんが、一度はPlayground等で手を動かしながら試してみることをオススメします。
明日のレコチョク Advent Calendarはいよいよ最終日、「CakePHPを用いたバッチ開発」です。お楽しみに!
この記事はレコチョクのエンジニアブログの記事を転載したものとなります。