はじめに
こんにちは。
iOS 13 で SwiftUI がリリースされてから早 2 年。
そろそろ SwiftUI をちゃんと学んでおこうと思い立ち、趣味プロダクトで SwiftUI の習作アプリを開発してみました。
出来上がったもの
小説投稿サイト 小説家になろう の公開 API を利用させていただき、該当サイトの閲覧アプリを開発しました。
動作イメージ
実装した機能
- 「日間」「週間」「月間」「四半期」の小説ランキング閲覧機能
- 小説の検索機能
- 小説の閲覧機能(ただの WebView ですが。。。)
実際のコード
以下のリポジトリに置いてあります。もし興味がありましたら是非触ってみてください。
https://github.com/inokinn/NaroViewer
アーキテクチャについて
SwiftUI を使ってみるにあたり、まず悩んだのはアーキテクチャの選定でした。
SwiftUI は Combine との相性が良く、折角なので Combine によるデータバインディングが活きる MVVM でやってみようと考えました。
とはいえ、 MVVM は所謂 GUI アーキテクチャであり、プレゼンテーションロジックとドメインロジックの切り離し以外は興味の対象外です。そこで、データの永続化や API へのアクセス周りに関しても総合的に勘案の上、 MVVM ベースの Clean Architecture について考えてみることにしました。
検討した結果のアーキテクチャ
アーキテクチャのルール
レイヤー構造は、 Domain
、 Presentation
、 Infrastructure
の 3 層構成なのですが、 Clean Architecture の有名な同心円状の図のレイヤー構造にも準拠しています。
( Entity
、 UseCase
、 Interface Adapter
、 Framework & Driver
の 4 層。)
この図の上から順に上位のレイヤーであることを表します(スペースの都合で UseCase
と Entity
が並んでいますが、 Entity
は最上位レイヤーです)。
この 2 通りのレイヤー構造のどちらにおいても、依存の方向は 下位レイヤー -> 上位レイヤー
であることを徹底しています。
上位レイヤーが下位レイヤーにアクセスする際には、必ず実装ではなくプロトコルに依存することによって、下位レイヤーに直接依存することを避けています(依存性逆転の原則)。
上位レイヤーのモジュールから下位レイヤーの参照を保持する場合、 Swinject を用いて依存性注入を行っています。
データの流れ
データの取得には Combine によるデータバインディングを用いています。
例として、API 通信を行って小説のランキングデータを取得する際の、データの流れは下図のようになります。
この部分について、ソースコードを抜粋したものが以下になります。
View から ViewModel のメソッドを呼び出す
import SwiftUI
import Combine
struct RankingView: View {
@ObservedObject var viewModel: RankingViewModel
@State var rankingType = Ranking.RankingType.Daily
var body: some View {
ZStack {
NavigationView {
(略)
}
.navigationViewStyle(StackNavigationViewStyle())
.onAppear {
if viewModel.ranking.rowList.count == 0 {
self.loadRanking()
}
}
(略)
}
}
func loadRanking() {
viewModel.fetchRanking(type: self.rankingType)
}
ここでは、画面が表示された際に ViewModel のメソッド呼び出しを行っています。
ViewModel にて、 UseCase の処理を購読する
import SwiftUI
import Combine
final class RankingViewModel: ObservableObject, Identifiable {
@Published var ranking: Ranking = Ranking(rowList: [])
@Published var loading: Bool = false
private var disposables = Set<AnyCancellable>()
// ランキングの取得
func fetchRanking(type: Ranking.RankingType) {
self.loading = true
self.ranking = Ranking(rowList: [])
AppBuilder.shared.rankingUseCase.startFetch(type: type)
.receive(on: DispatchQueue.main)
.sink(receiveCompletion: { completion in
self.loading = false
switch completion {
case .finished: break
case .failure(_):
// TODO: エラーハンドリングちゃんとやる
break
}
},
receiveValue: { [weak self] ranking in
self?.ranking = ranking
})
.store(in: &disposables)
}
}
ViewModel では UseCase の処理を購読し、結果を受け取って値を保持したりエラー処理を行っています(サンプルコードではエラー処理をちゃんとやってないけど。。。)。
UseCase でアプリケーション固有のロジックを書く
final class RankingUseCase: RankingUseCaseProtocol {
let rankingGateway: RankingGatewayProtocol
let rankingRowsGateway: RankingRowsGatewayProtocol
init(rankingGateway: RankingGatewayProtocol, rankingRowsGateway: RankingRowsGatewayProtocol) {
self.rankingGateway = rankingGateway
self.rankingRowsGateway = rankingRowsGateway
}
func startFetch(type: Ranking.RankingType) -> AnyPublisher<Ranking, Error> {
return rankingGateway
.fetch(type: type)
.flatMap { [weak self] ranking -> AnyPublisher<Ranking, Error> in
return (self?.rankingRowsGateway.fetch(ranking: ranking))!
}
.eraseToAnyPublisher()
}
}
UseCase です。
なろう API の仕様として、ランキング情報取得 API のレスポンスには、小説の識別子データはあるものの、実際の小説データ(タイトルや作者名など)は含まれませんので、ランキング情報を元に小説情報をリクエストする必要があります。
そこで、今回は Combine のオペレーターの一つである flatMap
を用いて、ランキング情報を持つ Publisher を、小説情報を取得する Publisher に変換し、 2 つの API アクセスを直列で実行しています。
このように、一連の処理の流れを宣言的に記述出来るのも Combine の利点です。
Gateway は下位レイヤーとドメインの橋渡しをする
final class RankingGateway: RankingGatewayProtocol {
let apiClient: RankingAPIClientProtocol
init(apiClient: RankingAPIClientProtocol) {
self.apiClient = apiClient
}
func fetch(type: Ranking.RankingType) -> AnyPublisher<Ranking, Error> {
// type をパラメータの rtype に変換
var rtype = ""
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyyMMdd"
dateFormatter.locale = Locale(identifier: "ja_JP")
let weekDayFormatter = DateFormatter()
weekDayFormatter.dateFormat = "EEEEE"
weekDayFormatter.locale = Locale(identifier: "ja_JP")
// 月初を取得
var components = Calendar.current.dateComponents([.year, .month, .day],from: Date())
components.day = 1
let firstDay = Calendar.current.date(from: components)!
switch type {
case .Daily:
// "yyyyMMdd-d" の形に変換
rtype = dateFormatter.string(from: Calendar.current.date(byAdding: .day, value: -1, to: Date())!) + "-d"
break
case .Weekly:
// 火曜日を特定した後、 "yyyyMMdd-w" の形に変換
var targerDay = Date()
while weekDayFormatter.string(from: targerDay) != "火" {
targerDay = Calendar.current.date(byAdding: .day, value: -1, to: targerDay)!
}
rtype = dateFormatter.string(from: targerDay) + "-w"
break
case .Monthly:
// 月初を特定した後、 "yyyyMMdd-m" の形に変換
rtype = dateFormatter.string(from: firstDay) + "-m"
break
case .Quarter:
// 月初を特定した後、 "yyyyMMdd-q" の形に変換
rtype = dateFormatter.string(from: firstDay) + "-q"
break
}
// レスポンスを Entity に変換し UseCase に返す
return apiClient.fetch(rtype: rtype)
.tryMap { response -> Ranking in
var rankingRows: [RankingRow] = []
for row in response {
rankingRows.append(RankingRow(ncode: row.ncode, pt: row.pt, rank: row.rank, novel: nil))
}
return Ranking(rowList: rankingRows)
}
.eraseToAnyPublisher()
}
}
ランキングの取得方式は「日間」「週間」「月間」「四半期」を選択することが可能です。
この API のリクエストパラメータには、多くのルールが存在します。
- 日間ランキングは、朝になるまでデータが生成されない(夜中に本日の日時を指定するとエラーになる)
- 週間ランキングなら集計の都合上、火曜日の日付を指定する必要がある
- 月間および四半期ランキングの場合、対象月の1日(20211001 など)を指定する必要がある
リクエストパラメータを作成する際には、これらを念頭に置かなければなりません。しかし、これは API の都合であり、アプリの仕様や要件には直接関係の無いものです。したがって、 Gateway は入力としては「ランキングのタイプは月間としてデータを取得したい」など、 API の仕様とは直接関係ないパラメータを受け取って API が必要とするパラメータに変換し、実際に API リクエストを行う最下レイヤーとの橋渡し役を担います。
また、後に受け取ったレスポンスを Entity のデータ構造に変換する役割も担っています。
APIClient でリクエストの生成を行う
import Alamofire
import Combine
final class RankingAPIClient: RankingAPIClientProtocol {
func fetch(rtype: String) -> AnyPublisher<[RankingResponse], Error> {
let request = RankingRequest()
request.rtype = rtype
request.out = "json"
return APIAccessPublisher.publish(request).eraseToAnyPublisher()
}
}
APIAccessPublisher が実際の API アクセスを行う
import Alamofire
import Combine
import Foundation
struct APIAccessPublisher {
private static let contentType = "application/json"
private static let decoder: JSONDecoder = {
let jsonDecoder = JSONDecoder()
jsonDecoder.keyDecodingStrategy = .convertFromSnakeCase
return jsonDecoder
}()
static func publish<T, V>(_ request: T) -> Future<V, Error>
where T: BaseRequest, V: Codable, T.ResponseType == V {
return Future { promise in
let api = AF
.request(request)
.responseJSON { response in
switch response.result {
case .success:
do {
if let data = response.data {
let json = try self.decoder.decode(V.self, from: data)
promise(.success(json))
} else {
promise(.failure(AFError.responseValidationFailed(reason: .dataFileNil)))
}
} catch {
promise(.failure(AFError.responseValidationFailed(reason: .dataFileNil)))
}
case .failure:
promise(.failure(AFError.responseValidationFailed(reason: .dataFileNil)))
}
}
api.resume()
}
}
}
APIAccessPublisher は、 Alamofire を用いて実際に API 通信を行います。
レスポンスのデータ構造を定義しておき、 json をそのデータ構造に変換します。
結果は promise()
を用いて Publish します。
機能追加のために新規で API を作成する場合でも、実際に通信を行うのはこのクラスになります。
ちなみに、レスポンスデータは Entity とデータ構造が類似しており、使いまわして直接 Entity に格納したいと感じることもありますが、これらのデータ構造の目的は全く異なり、それぞれ違う理由で変更が入るため、単一責任の原則に反するため、使い回さない方がよいとされています。
ところで、先述のとおり、この例では直列でもう一つ API を叩くのですが、もうひとつの APIClient についてはここでは割愛します。
依存性の注入
アーキテクチャのルール で述べた通り、上位レイヤーが下位レイヤーにアクセスする際には、必ず実装ではなくプロトコルに依存することによって、下位レイヤーに直接依存することを避けています。
そこで、上位レイヤーのモジュールから下位レイヤーの参照を保持するために、 Swinject を用いて下記のように依存性の注入(DI)を行っています。
import Swinject
final class AppBuilder {
static let shared = AppBuilder()
let rankingUseCase: RankingUseCaseProtocol
let searchNovelUseCase: SearchNovelUseCaseProtocol
// DI コンテナに Service を登録
let swinjectContainer = Container() { c in
// フレームワーク・ドライバ
c.register(RankingAPIClientProtocol.self) { _ in RankingAPIClient() }
c.register(RankingRowsAPIClientProtocol.self) { _ in RankingRowsAPIClient() }
c.register(SearchNovelAPIClientProtocol.self) { _ in SearchNovelAPIClient() }
// インターフェイスアダプター
c.register(RankingGatewayProtocol.self) { r in
RankingGateway(apiClient: r.resolve(RankingAPIClientProtocol.self)!)
}
c.register(RankingRowsGatewayProtocol.self) { r in
RankingRowsGateway(apiClient: r.resolve(RankingRowsAPIClientProtocol.self)!)
}
c.register(SearchNovelGatewayProtocol.self) { r in
SearchNovelGateway(apiClient: r.resolve(SearchNovelAPIClientProtocol.self)!)
}
// ユースケース
c.register(RankingUseCaseProtocol.self) { r in
RankingUseCase(rankingGateway: r.resolve(RankingGatewayProtocol.self)!,
rankingRowsGateway: r.resolve(RankingRowsGatewayProtocol.self)!)
}
c.register(SearchNovelUseCaseProtocol.self) { r in
SearchNovelUseCase(searchNovelGateway: r.resolve(SearchNovelGatewayProtocol.self)!)
}
}
private init() {
// ユースケースの作成
self.rankingUseCase = self.swinjectContainer.resolve(RankingUseCaseProtocol.self)!
self.searchNovelUseCase = self.swinjectContainer.resolve(SearchNovelUseCaseProtocol.self)!
}
}
おわりに
今回は、 SwiftUI + Combine (と Swinject) で MVVM & Clean Architecture を構築し、アプリを作成してみました。
とはいえ SwiftUI ならではという話は結果的に皆無になってしまったので、 UIKit でも同じような感じになると思います。
Combine を初めて使った際、データの伝播のさせ方や直列による処理の書き方などがあまり分からず、やや手探りでの実装となりましたが、何となく慣れてきた今はこのように宣言的に記述出来るのは楽でよいなと感じています。
SwiftUI は現時点でまだまだ UIKit で出来ていたことには及ばず、不便を感じることが多いですが、既に新規プロダクトでは SwiftUI で開発しているという事例もいくつか耳にするようになったため、引き続き SwiftUI でやっていけるアーキテクチャを模索したいと思います。