🦍

[Swift]設計してみた

2024/04/17に公開

はじめに

こんにちは、ゆきおです。
転職してから一年半、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の教材で知ったので使っております。
https://hn.algolia.com/api

モデル定義

まず、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