[Swift]設計してみた
はじめに
こんにちは、ゆきおです。
転職してから一年半、Vue/LaravelでWebアプリ、Unityや8thWall、ZapparでARアプリ、現在ではiOSをメインにモバイル開発にチャレンジしています。
今日はタイトル通りアーキテクチャを設計してみたという話をまとめたく筆を執った次第です。
きっかけ
ある日突然、経験のないSwift案件に放り込まれ右も左もわからず取り組んだ結果
「よくわかんないけどMVVMで行こう!」
となって無事爆死したので設計についてキャッチアップしていたところ、SwiftUIやJetpackComposeが導入されてからというもの国内外でViewModelを廃止し、新たなアーキテクチャを考えましたみたいな方をけっこう見かけたので自分なりに一度考えてみようと思い立ちました。
そもそもViewModelというもの自体iOSでもAndroidでもなくMicroSoft発信らしいですね。
一応、クリーンアーキテクチャやプリンシプルオブプログラミングなどは気が向いたら読んでました。
実際に設計してみた
まず取り掛かったこととして、ChatGPTや最近話題のClaude3と相談しつつ自分なりに分かりやすい設計というものを目指してディレクトリ構成やコアロジックについてアレコレ考えてみました。
複雑に考えられるほど経験も知識もないので単純にMVCベースでControllerがやってそうなことや、アプリを触っていて発生している事象を分解して設計しました。
イメージとしては工場のラインです。
車で言うとパーツやシャーシがコンベアで運ばれていって、組み立て担当がいたり着色担当がいたりとそれぞれが担う役割をこなすことで最終的に車が出来上がるみたいなイメージですね。
大まかな解説
まずアーキテクチャの構造を以下のように構成してみました
・Model
・View
・Interactor
・Executor
・Observer
Interactor、Executor、Observerという3つのモジュールに分解しています。
これに加えてAPIClientなどを作成してサンプルアプリを構築してみました。
内容は「Hacker NewsのAPIを叩いてランダムに記事を10件取得する」というものです。
そのうちFirebase使ってログインシステムを作ったり、これをベースに肉付けしていこうと思います。
HackerNewsのAPIはAlgoriaという所からAPIを使用できるというのをUdemyの教材で知ったので使っております。
モデル定義
まず、Modelから定義します。
APIのレスポンスと、レスポンス内のhitsの中身を「NewsItem」として定義します。
アプリのコアとなるものは「Entity」、APIに関するものは「Request」や「Responce」としています。
APIレスポンス
struct NewsResponse: Codable {
let hits: [NewsItem]
let nbHits: Int
let page: Int
let nbPages: Int
let hitsPerPage: Int
let exhaustiveNbHits: Bool
let exhaustiveTypo: Bool
let exhaustive: Exhaustive
let query: String
let params: String
let processingTimeMS: Int
let processingTimingsMS: ProcessingTimingsMS
let serverTimeMS: Int
struct Exhaustive: Codable {
let nbHits: Bool
let typo: Bool
}
struct ProcessingTimingsMS: Codable {
let fetch: Fetch?
let total: Int
struct Fetch: Codable {
let total: Int
}
}
}
次にNewsItemです。
表示に必要なタイトルや投稿者、ポイントやコメント数など定義します。
今後記事詳細ページなど作っていく予定なので中身も取得しておきます。
struct NewsItem: Codable, Identifiable {
let id: String
let createdAt: String
let title: String?
let url: String?
let author: String
let points: Int?
let storyText: String?
let numComments: Int
let storyId: Int
let storyTitle: String?
let storyURL: String?
let parentId: Int?
let createdAtI: Int
let tags: [String]
let highlightResult: HighlightResult?
enum CodingKeys: String, CodingKey {
case id = "objectID"
case createdAt = "created_at"
case title
case url
case author
case points
case storyText = "story_text"
case numComments = "num_comments"
case storyId = "story_id"
case storyTitle = "story_title"
case storyURL = "story_url"
case parentId = "parent_id"
case createdAtI = "created_at_i"
case tags = "_tags"
case highlightResult = "_highlightResult"
}
struct HighlightResult: Codable {
let author: HighlightResultValue?
let storyText: HighlightResultValue?
let title: HighlightResultValue?
let url: HighlightResultValue?
enum CodingKeys: String, CodingKey {
case author
case storyText = "story_text"
case title
case url
}
}
struct HighlightResultValue: Codable {
let value: String
let matchLevel: String
let matchedWords: [String]
}
}
View完成イメージ
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3562437/f4a698ef-8ec6-190d-909d-7515013306a4.png" width="250">
Viewはこんな感じでニュースが10件、リストで表示されPull to Refresh(画面最上部で下にスワイプして更新するアレ)で再度ランダムに10件フェッチして再表示します。
今思うとこの場合画面最上部に行った時に「下に引っ張って更新」みたいな表示必要ですね。
現段階ではこのページのみなのでSwiftUIの大元ビューファイルのContentViewに直で書いてしまってます。
NewsItem単体のレイアウトも下に書いてます。
import SwiftUI
struct ContentView: View {
@StateObject private var newsObserver: NewsObserver
private let newsExecutor: NewsExecutor
private let newsInteractor: NewsInteractor
init() {
let apiClient = APIClient(baseURL: AppConstants.baseURL)
let executor = NewsExecutor(apiClient: apiClient)
let observer = NewsObserver()
_newsObserver = StateObject(wrappedValue: observer)
newsExecutor = executor
newsInteractor = NewsInteractor(executor: executor, observer: observer)
}
var body: some View {
NavigationView {
ZStack {
List(newsObserver.newsItems) { newsItem in
NewsItemView(newsItem: newsItem)
}
.listStyle(PlainListStyle())
.refreshable {
newsInteractor.onRefreshRequested()
}
}
.navigationBarTitle("Hacker News")
}
.onAppear {
newsInteractor.onViewAppeared()
}
.alert(item: $newsObserver.error) { error in
Alert(title: Text("Error"), message: Text(error.localizedDescription), dismissButton: .default(Text("OK")))
}
}
}
struct NewsItemView: View {
let newsItem: NewsItem
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text(newsItem.title ?? "")
.font(.headline)
Text("By \(newsItem.author)")
.font(.subheadline)
.foregroundColor(.secondary)
Text("\(newsItem.points ?? 0) points")
.font(.subheadline)
.foregroundColor(.secondary)
Text("\(newsItem.numComments) comments")
.font(.subheadline)
.foregroundColor(.secondary)
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
SwiftUIはドット記法でスタイルやアラートなど宣言的に呼び出せるので非常に便利ですね。
まずAPIの大元となるAPIClientから実装します。
プロトコル(インターフェース)を実装し、それを継承しています。
今後POSTリクエストも作ろうと思っているのでsendを作っていますが現状ではfetchのみを使用しています。
import Foundation
import Combine
protocol APIClientProtocol {
var baseURL: URL { get }
var path: String { get set }
var headers: [String: String]? { get }
var queryItems: [URLQueryItem]? { get set }
func fetch<T: Decodable>() -> AnyPublisher<T, Error>
func send<T: Decodable, U: Encodable>(body: U) -> AnyPublisher<T, Error>
}
import Foundation
import Combine
class APIClient: APIClientProtocol {
var baseURL: URL
var path: String
var headers: [String: String]?
var queryItems: [URLQueryItem]?
init(baseURL: URL, path: String = "", headers: [String: String]? = nil, queryItems: [URLQueryItem]? = nil) {
self.baseURL = baseURL
self.path = path
self.headers = headers
self.queryItems = queryItems
}
func fetch<T: Decodable>() -> AnyPublisher<T, Error> {
var components = URLComponents(url: baseURL.appendingPathComponent(path), resolvingAgainstBaseURL: false)
components?.queryItems = queryItems
guard let url = components?.url else {
let error = NSError(domain: "APIClient", code: 0, userInfo: [NSLocalizedDescriptionKey: "Invalid URL"])
return Fail(error: error).eraseToAnyPublisher()
}
var request = URLRequest(url: url)
request.httpMethod = "GET"
request.allHTTPHeaderFields = headers
return URLSession.shared.dataTaskPublisher(for: request)
.map(\.data)
.decode(type: T.self, decoder: JSONDecoder())
.eraseToAnyPublisher()
}
func send<T: Decodable, U: Encodable>(body: U) -> AnyPublisher<T, Error> {
var components = URLComponents(url: baseURL.appendingPathComponent(path), resolvingAgainstBaseURL: false)
components?.queryItems = queryItems
guard let url = components?.url else {
let error = NSError(domain: "APIClient", code: 0, userInfo: [NSLocalizedDescriptionKey: "Invalid URL"])
return Fail(error: error).eraseToAnyPublisher()
}
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.allHTTPHeaderFields = headers
request.httpBody = try? JSONEncoder().encode(body)
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
return URLSession.shared.dataTaskPublisher(for: request)
.map(\.data)
.decode(type: T.self, decoder: JSONDecoder())
.eraseToAnyPublisher()
}
}
ここの実装はイマイチイケてない気がしてならないので今後かなり変更すると思います。
CombineとURLSessionを使用してAPIを実装していますがAlamoFireに置き換えるとか、HTTPメソッドやパラメータなど引数に突っ込んでしまおうかと考えています。
ここで定義したfetchメソッドを後ほど利用します。
URLの定義もConstantsとして定義していますがそのうちinfo.plistから取得するようにします。
enum AppConstants {
static let baseURL = URL(string: "https://hn.algolia.com/api/v1/")!
}
Interactor
ということで順を追って実装していきます。
まずは「ユーザーがなんかした」時の「なんか」を定義します。イベントですね。
import Foundation
import Combine
class NewsInteractor {
private let executor: NewsExecutor
private let observer: NewsObserver
init(executor: NewsExecutor, observer: NewsObserver) {
self.executor = executor
self.observer = observer
}
func onFetchRandomNewsButtonTapped() {
observer.onNewsItemsUpdated(from: executor)
}
func onRefreshRequested() {
observer.onNewsItemsUpdated(from: executor)
}
func onViewAppeared() {
observer.onNewsItemsUpdated(from: executor)
}
}
「ボタンがタップされたよ」とか「PulltoRefreshが実行されたよ」とか「ページ開いたよ」とかをObserverにお知らせします。
Executor
Observerがイベントに対応するメソッドを実行するのですがその中身(APIリクエスト自体)はExecutorが担当します。
import Foundation
import Combine
class NewsExecutor {
private let apiClient: APIClient
init(apiClient: APIClient) {
self.apiClient = apiClient
}
func fetchRandomNews() -> AnyPublisher<[NewsItem], Error> {
apiClient.path = "search"
apiClient.queryItems = [URLQueryItem(name: "tags", value: "front_page")]
return apiClient.fetch()
.map { (response: NewsResponse) -> [NewsItem] in
print("API Response: \(response)")
let randomItems = response.hits.shuffled().prefix(10)
return Array(randomItems)
}
.eraseToAnyPublisher()
}
}
ここでAPIClientの出番ですね。必要なパラメータを渡してfetchメソッドを実行するだけで済みます。
Observer
ということでObserverが↑のメソッドを実行します。
今のところ各モジュールのお仕事が少ないのでイマイチピンとこない気がしてきました。
import Foundation
import Combine
class NewsObserver: ObservableObject {
@Published var newsItems: [NewsItem] = []
@Published var error: NewsError?
@Published var isLoading: Bool = false
private var cancellables = Set<AnyCancellable>()
func onNewsItemsUpdated(from executor: NewsExecutor) {
isLoading = true
executor.fetchRandomNews()
.receive(on: DispatchQueue.main)
.sink(receiveCompletion: { [weak self] completion in
self?.isLoading = false
switch completion {
case .finished:
print("API request completed successfully.")
case .failure(let error):
print("API request failed with error: \(error)")
self?.onErrorOccurred(error)
}
}, receiveValue: { [weak self] newsItems in
print("Received \(newsItems.count) news items.")
self?.newsItems = newsItems
})
.store(in: &cancellables)
}
func onErrorOccurred(_ error: Error) {
print("Error occurred: \(error)")
self.error = NewsError(error: error)
}
}
ここでExecutorの実行結果をチェックし、成功や失敗をハンドリングします。
ほんで取得結果を先ほどのViewに反映させて完成です。
まとめ
出来上がった時は良い感じに思えてもこうしてブログなどにアウトプットすると俯瞰的になってアレコレ気になってきますね…
他にも意識した点としては
・Interactorはどんなイベントが発生したか
・Executorは内部で何が起きているか
・Observerは外部に向けて実際に何をやっているか
といったイメージで命名規則を設けています。
今コードを見返してみるとObserverは他にもっと良いネーミングありそうですね。
データを格納して持っているのでRepositoryとかのが良い気がしてきました。
その上でどのイベントが発生したかをObserverがチェックして処理を振り分けるとかしたら良いかもしれません。備忘録。
また一連の処理を分割することでログデバッグのポイントも置きやすくしています。
Executorの時点でエラーが起きてるのか、Observerの時点でエラーが起きているのかログを見てすぐ判断できるようにしています。
というわけでざっくりとしたアーキテクチャの紹介でした。
これからどんどんアップデートし次第ブログも更新していこうと思います。
最後までご覧いただきありがとうございました。
Discussion