🌐

MVVM+Repositoryを理解する

に公開

はじめに

この記事では、MVCに慣れていたエンジニアがMVVM+Repositoryの概念を
理解するための解説をします。

  • この記事の対象
    • MVVM+Repositoryを学び始めた人
  • 得られる知識
    • MVVMの手法
    • 各層でのAPI、データの扱い
  • 注意事項
    • エラーハンドリングやプロトコルの作成、DIなどを省略しています。
    • あくまでMVVMとRepositoryに焦点を当てた記事です。
    • 今回は見やすさ重視のため、ファイル分割や定数ファイルの作成などは最小限にしています。実際のプロジェクトでは、適宜分割して運用してください。

背景・問題意識

参加しているプロジェクトのコードがMVCでしたが、処理が散らかってしまったり肥大化してコードが追い辛くなってしまいました。
MVVM+Repositoryにリファクタリングすることになったため、アーキテクチャの理解を深めます。

今回作成したアプリ

以下のように、ボタンを押すと画面遷移してAPIの結果を表示するシンプルなものです。

サンプルアプリ

環境

  • OS: iOS26.0
  • 言語: swift
  • フレームワーク: UIKit

MVCとMVVM

※筆者が参画していたプロジェクトの実情を元にした内容です。

MVC

MVCでは、書き方によってはViewとビジネスロジック等が依存・混在してしまうことがあります。
特にswiftではViewControllerが、kotlinではActivityやFragmentが

  • 直接APIを呼ぶ
  • APIの結果を都度parse、エラーハンドリングする
  • レスポンスに応じて都度対象UIを手動更新する

と言った状態になっていました。これではどのUIがどのデータを表示しているのか、
複数の処理やUIがどのように繋がっているのか一目で分かりにくい構造になっています。
仕様変更があった際に、viewContorollerを全面的に、漏れなく精査する必要が出てきます。

MVVM

MVVMでは、viewは常にUIの表示、入力の受付のみに専念します。
view側が、

  • viewModelにイベント(ボタンタップ・フォアグラウンド復帰など)を送信する
  • viewは常にview用のstateのみを監視する
  • viewはstateの更新に応じてUIを自動更新する

と言った状態になります。こうすることで、実際にボタンをタップしてもどのような処理を行うかは、viewModel側に隠蔽されます。viewはただUIに専念することになるので、
処理を追いたくなった場合はviewModelのみを参照すればわかるようになります。

MVVM実装サンプル

※筆者が学習した手法のMVVM実装を、今回はswiftで提示します。他にももっと良い実装があるかもしれません。今回はエラーハンドリング、エラー表示は割愛しております。

トップ画面View側実装 「WeatherViewController」

ここはViewの更新を行わない画面なので、viewModelは使用していません。

import UIKit
import Then

class WeatherViewController: UIViewController {

    private let forecastButton = UIButton()

    // 画面生成時にバインド処理
    override func viewDidLoad() {
        super.viewDidLoad()
        setupViews()
        bindInput()
    }

    /// 入力イベントバインド(今回は直接画面遷移を記載)
    private func bindInput() {
        forecastButton.addAction(
            UIAction { [weak self] _ in
                let viewModel = WeatherDetailViewModel(repository: WeatherRepository())
                let detailVC = WeatherDetailViewController(viewModel: viewModel)
                self?.navigationController?.pushViewController(detailVC, animated: true)
            },
            for: .touchUpInside
        )
    }
}

extension WeatherViewController {
    /// 初期レイアウト処理を行う
    private func setupViews() {
        // 省略
    }
}

天気予報画面View側実装 「WeatherDetailViewController」

APIの結果によって表示を更新したいため、stateを購読しています。
イベントの入力をinput、stateの更新outputと考え
処理をbindInput()とbindOutput()に分ける思想です。
今回のinputは画面ロード時のみなため、直接メソッドを介さずバインドしています。
inputとoutputを分けるだけでも、処理の流れは追いやすくなります。

import Foundation
import Then
import SVProgressHUD
import Combine

class WeatherDetailViewController: UIViewController {
    private let dateLabel = UILabel()
    private let titleLabel = UILabel()
    private let contentTextView = UITextView()

    private let viewModel: WeatherDetailViewModelInput & WeatherDetailViewModelOutput
    private var cancellables = Set<AnyCancellable>()

    init(viewModel: WeatherDetailViewModelInput & WeatherDetailViewModelOutput) {
        self.viewModel = viewModel
        super.init(nibName: nil, bundle: nil)
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    // バインド処理を行う
    override func viewDidLoad() {
        super.viewDidLoad()
        setupViews()
        bindOutput()

        // 画面ロード時イベントをviewModelに送信
        viewModel.onEvent(.viewDidLoad)
    }

    /// stateを監視し、viewを自動で更新させる
    private func bindOutput() {
        viewModel.uiState
            .map(\.dateText)
            .receive(on: DispatchQueue.main)
            .assign(to: \UILabel.text, on: dateLabel)
            .store(in: &cancellables)

        viewModel.uiState
            .map(\.titleText)
            .receive(on: DispatchQueue.main)
            .assign(to: \UILabel.text, on: titleLabel)
            .store(in: &cancellables)

        viewModel.uiState
            .map(\.bodyText)
            .receive(on: DispatchQueue.main)
            .assign(to: \UITextView.text, on: contentTextView)
            .store(in: &cancellables)

        viewModel.uiState
            .map(\.isLoading)
            .removeDuplicates()
            .receive(on: DispatchQueue.main)
            .sink { isLoading in
                isLoading ? SVProgressHUD.show() : SVProgressHUD.dismiss()
            }
            .store(in: &cancellables)
    }
}

extension WeatherDetailViewController {
    /// レイアウト処理を行う
    private func setupViews() {
        // 省略
    }
}

天気予報画面ViewModel側実装 「WeatherDetailViewModel」

viewからのeventに応じて処理(APIリクエスト)を行い、stateを更新しています。
inputとoutputで分けて考え、inputを受け取ったのちに対応するoutputを出力するように線を繋ぎます。
※実際にはここで適宜DIを使用してください。

import Combine

struct WeatherDetailUiState {
    var isLoading: Bool = false
    var dateText: String = ""
    var titleText: String = ""
    var bodyText: String = ""
    var error: AppError? = nil
}
enum WeatherDetailEvent {
    case viewDidLoad
}
protocol WeatherDetailViewModelInput {
    func onEvent(_ event: WeatherDetailEvent)
}
protocol WeatherDetailViewModelOutput {
    var uiState: AnyPublisher<WeatherDetailUiState, Never> { get }
}


@MainActor
final class WeatherDetailViewModel: WeatherDetailViewModelInput, WeatherDetailViewModelOutput {
    private let uiStateSubject = CurrentValueSubject<WeatherDetailUiState, Never>(.init())
    var uiState: AnyPublisher<WeatherDetailUiState, Never> {
        uiStateSubject.eraseToAnyPublisher()
    }

    private let repository: WeatherRepository
    static let regionCode = "130010"

    init(repository: WeatherRepository) {
        self.repository = repository
    }

    /// viewから受け取ったイベントを元に、処理を行なってstateを更新する
    func onEvent(_ event: WeatherDetailEvent) {
        switch event {
        case .viewDidLoad:
            loadWeather()
        }
    }

    // ここでAPIを呼ぶ
    private func loadWeather() {
        updateState { state in
            state.isLoading = true
            state.error = nil
        }

        Task { [weak self] in
            guard let self else { return }

            do {
                let weather = try await repository.getWeather(regionCode: WeatherDetailViewModel.regionCode)
                updateState { state in
                    state.isLoading = false
                    state.dateText = weather.date
                    state.titleText = weather.title
                    state.bodyText = weather.body
                    state.error = nil
                }
            } catch let domainError as DomainError {
                // 省略
            }
        }
    }

    /// viewが監視しているstateを更新する
    private func updateState(_ change: (inout WeatherDetailUiState) -> Void) {
        var state = uiStateSubject.value
        change(&state)
        uiStateSubject.send(state)
    }
}

このようにしてMVVMの実装が完成しました。

RepositoryとDataSource

私が参画していたプロジェクトでは、viewControllerからAPIClientを呼ぶ形でした。
しかし、これではパース処理が都度発生したり、テストの難易度が上がるようです。
そこでAPIを呼ぶ、データを取得する という行為を以下のレイヤーに分割して考えます。

  • APIClient
    • ここでは単にHTTP通信を提供するのみの役割
    • 生のデータを返す
  • RemoteDataSource・LocalDataSource
    • 欲しいデータの種類ごとにDataSourceを分ける
    • 生のデータを、コーディングする上で扱いやすい形に変換して取得する(structなどにparse)
  • Repository
    • 各DataSourceのデータを束ねる
    • アプリとして意味のある形にデータを変換する(変数名の調整や、enumで意味づけ等)
  • (ViewModel)
    • Repositoryから欲しいデータを取得する
    • そのデータを、UIの表示用に整形する

実装サンプル

参照:天気予報 API(livedoor 天気互換)

APIClient

ここではあくまで生のデータ、エラーをそのまま返します。
実務ではもう少し詳細なエラーハンドリングを推奨しますが、サンプルのため割愛します。

import Foundation
import Alamofire

enum APIClientError: Error {
    case network(AFError)
    case invalidResponse
    case unknown(Error)
}

final class APIClient {
    static let shared = APIClient()

    private init() {/*何もしない*/}

    func get(
        _ url: String,
        query: Parameters? = nil,
        headers: HTTPHeaders? = nil
    ) async throws -> Data {
        try await request(
            url,
            method: .get,
            parameters: query,
            headers: headers
        )
    }

    private func request(
        _ url: String,
        method: HTTPMethod,
        parameters: Parameters? = nil,
        headers: HTTPHeaders? = nil
    ) async throws -> Data {
        try await withCheckedThrowingContinuation { continuation in
            AF.request(
                url,
                method: method,
                parameters: parameters,
                encoding: (method == .get) ? URLEncoding.default : JSONEncoding.default,
                headers: headers
            )
            .validate()
            .responseData { response in
                switch response.result {
                case .success(let data):
                    continuation.resume(returning: data)
                case .failure(let error):
                    if let afError = error.asAFError {
                        // 省略
                    } else {
                        continuation.resume(throwing: APIClientError.unknown(error))
                    }
                }
            }
        }
    }
}

RemoteDataSource

DataSourceでは、生のデータをパースし使いやすい形にします。
あくまでデータの提供がメインの役割で、整形等は行いません。

import Foundation
import SwiftyJSON

struct WeatherDTO {
    let publicTime: String
    let title: String
    let bodyText: String

    init(json: JSON) {
        self.publicTime = json["publicTime"].stringValue
        self.title = json["title"].stringValue
        self.bodyText = json["description"]["text"].stringValue
    }
}

final class WeatherRemoteDataSource {
    func fetchWeather(regionCode: String) async throws -> WeatherDTO {
        let url = APIUrl.weatherForecastUrl(regionCode: regionCode)
        let data = try await APIClient.shared.get(url)
        let json = try JSON(data: data)
        return WeatherDTO(json: json)
    }
}

struct APIUrl {
    private static let weatherBaseUrl = "https://weather.tsukumijima.net/api/forecast/city/"
    static func weatherForecastUrl(regionCode: String) -> String {
        return "\(weatherBaseUrl)\(regionCode)"
    }
}

Repository

ここでは、使いたいデータをDataSource(複数でも可)から取得して束ねます。
データやエラーを、アプリ上意味のある形にします。
先ほどのViewModelでは、このRepositoryから取得したデータを元に、
表示しやすいuiStateに変換していました。

import Foundation

enum DomainError: Error {
    case noInternet
    case serverProblem
    case invalidData
    case unknown
}

extension DomainError {
    static func from(apiError: APIClientError) -> DomainError {
        switch apiError {
        case .network:
            return .noInternet

        case .invalidResponse:
            return .serverProblem

        case .unknown:
            return .unknown
        }
    }
}

struct Weather {
    let date: String
    let title: String
    let body: String
}

final class WeatherRepository {
    private let remote = WeatherRemoteDataSource()

    func getWeather(regionCode: String) async throws -> Weather {
        do {
            let dto = try await remote.fetchWeather(regionCode: regionCode)
            return dto.toDomain()

        } catch let apiError as APIClientError {
            throw DomainError.from(apiError: apiError)
        } catch {
            throw DomainError.unknown
        }
    }
}

extension WeatherDTO {
    func toDomain() -> Weather {
        Weather(
            date: publicTime,
            title: title,
            body: bodyText
        )
    }
}

終わりに

広く浅い内容のため、コードの羅列が多くなってしまいましたが、
この構成にするとロジックを変更したい場合にView側を一切見る必要が無いことがわかります。
今後はこの構成に、DI、テスト用のプロトコルの作成までを研究していきたいです。

株式会社ソニックムーブ

Discussion