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の表示用に整形する
実装サンプル
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