17
11

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

フラー株式会社Advent Calendar 2021

Day 20

SwiftUI + CombineでMVVMサンプルアプリを作ってみた!

Last updated at Posted at 2021-12-19

この記事は,フラー株式会社 Advent Calendar 2021 の 20日目の記事です。

19日目の記事は @ujikawa1026 さんによる 「コーディングしない」という不安に対してマネージャーはどう向き合うか でした。

はじめに

こんにちは!フラーでiOSエンジニアのアルバイトをしている前澤です。

これまでのiOS開発では主にUIKitRxSwiftを使ったMVVM設計パターンによる開発をしてきました。一方で比較的新しいフレームワークであるSwiftUI, Combineについてはほとんど触ったことがありませんでした。
ただ、SwiftUIのコードベースで宣言的にUIを実装していける点が魅力的だと感じていました。また、CombineはApple標準のリアクティププログラミングのフレームワークでこちらもぜひ使ってみたいと思ってました。
そこで今回SwiftUI + Combineを使ったサンプルアプリを自身の勉強のアウトプットとして作ってみることにしました!

この記事では主にSwiftUIとCombineを使いMVVMでどう実装するかということについて、実装してみて分かったこと、気づいたことについて自分なりにまとめてみました!
実際に作成したものはこちら

サンプルアプリについて

概要

今回は映画情報を取得し一覧で表示できる映画アプリをつくりました。TMDBという映画に関する色々な情報を提供しているAPIを使いました。こちら

検索バーに文字を入力し、映画タイトルの検索結果を一覧で表示したり、映画の詳細を表示できるアプリです。
こちらのアプリ、時間の問題でまだ実装できてない画面などあるので、今後仕上げていきたいと思ってます。(時間があれば)

使用ライブラリ

  • SwiftUI
  • Combine
  • APIKit(今回はあまり重要ではない)

環境

  • Xcode 13.1
  • Swift 5.5.1

実装について

このアプリの設計パターンはMVVMで実装しました。View, ViewModel, Model(APIでデータを取得)をそれぞれつくり、それら各レイヤー間でのデータの受け渡しをCombineを使う感じで実装しました。

View

SearchMovieView.swift
import SwiftUI

struct SearchMovieView: View {

    @ObservedObject var viewModel: SearchMovieViewModel

    var body: some View {
        NavigationView {
            VStack {
                HStack {
                    TextField("Search movies...", text: $viewModel.searchBarText)
                        .frame(height: 40)
                        .padding(EdgeInsets(top: 0, leading: 8, bottom: 0, trailing: 8))
                        .border(Color.gray, width: 1)
                        .padding(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16))
 
                    Button<Text>(LocalizedStringKey("Search")) { self.viewModel.searchButtonClickedInput.send(()) }
                        .frame(height: 40)
                        .padding(EdgeInsets(top: 0, leading: 8, bottom: 0, trailing: 8))
                        .border(Color.blue, width: 1)
                        .padding(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 16))
                }

                List {
                    ForEach(viewModel.movies) { movie in
                        
                        NavigationLink(destination: MovieDetailView(viewModel: MovieDetailViewModel(with: movie))) {
                            SearchMovieCellView(viewModel: SearchMovieCellViewModel(with: movie))
                        }
                    }
                }
                .listStyle(.plain)
                .navigationTitle(Text("Search Movie 🔍"))
            }
        }
    }
}

まず、viewModel@ObservedObjectで保持しています。この@ObservedObjectをつけることでObservableObjectに準拠したviewModelのプロパティの変更を検知できるようになります。

viewModelから受け取った値をUIに反映します。

TextFieldButtonListを使いそれぞれのUIをコードで設定して実装しています。

SwiftUIの良いと思ったところ

  • TableView, CollectionViewの実装(ストリームにitem流してバインドさせる)や各セクション内に横スクロールViewを入れるような実装がかなりやりやすい
  • Combineとのシナジーが高く、一緒に使うことでデータバインディングがかなりやりやすい
  • 宣言的なUIの実装が直感的で分かりやすい
  • Viewごとにそれぞれつくり、それを組み合わせて1つの画面を構成させるのが実装しやすく、良い感じ。

ViewModel

続いてViewModelです。こちらはInputとOutputを以下のようにそれぞれプロパティとして実装しました。
Outputに関しては、それぞれ、プロパティラッパーである@Publishedをつけるやり方で実装しました。@Publishedによって、その変数が変更された場合に、それがObservableイベントとして通知されるようになるみたいです。

ViewModel.swift
import Combine
import SwiftUI

final class SearchMovieViewModel: ObservableObject {
    // MARK: - Inputs
    var searchBarText: String = ""
    let searchButtonClickedInput = PassthroughSubject<Void, Never>()
    
    // MARK: - Outputs
    @Published private(set) var movies: [Movie] = []
    @Published private(set) var errorMessage: String? = nil
    @Published private(set) var isLoasing = false
    
    private var cancellables = Set<AnyCancellable>()
    
    init(movieRepository: MovieRepositoryProtocol = MovieRepository()) {
        let searchEvent = searchButtonClickedInput
            .map { [unowned self] in self.searchBarText }
            .filter { !$0.isEmpty }
            .debounce(for: .milliseconds(300), scheduler: DispatchQueue.main)
            .removeDuplicates()
        
        let apiResponse = searchEvent
            .flatMap {
                return movieRepository.searchMovies(query: $0)
            }
            .share()
        
        apiResponse
            .sink(receiveCompletion: { completion in
                switch completion {
                case .finished:
                    print("finished")
                    self.isLoasing = false
                case .failure(let error):
                    print("failure")
                    print(error.localizedDescription)
                    self.isLoasing = false
                }
            }, receiveValue: { response in
                print(response)
                self.movies = response.results
            })
            .store(in: &cancellables)
    }

Repository

RepositoryではAPIを叩いて取得したデータをCombineのストリームとして流します。
こちらのメソッドの戻り値のObservableについては1回のみ値が流れれば良いのでFuture型で実装しました。
基本的にAPIを叩く処理では、1回のリクエストに対して、成功したか失敗したかという結果を1度受け取れれば良いので、イベントを1回だけ流すObservableであるFutureが適していると思われます。
値の取得に成功したら.success(response)、エラーの場合は.failure(error)でFutureのイベントを流すようにしました。

MovieRepository.swift
import Combine
import APIKit

protocol MovieRepositoryProtocol {
    func searchMovies(query: String) -> Future<SearchResponse<Movie>, Error>
}

final class MovieRepository: MovieRepositoryProtocol {
    func searchMovies(query: String) -> Future<SearchResponse<Movie>, Error> {
        let request = MovieAPI.SearchMovieRequest(query: query)
        return Future<SearchResponse<Movie>, Error> { promiss in
            Session.send(request) { result in
                switch result {
                case .success(let response):
                    promiss(.success(response))
                case .failure(let error):
                    promiss(.failure(error))
                }
            }
        }
    }
}

分かったこと

 SwiftUIでViewを実装していく際の宣言的なコーディングに関して、これはSwift5.4で新たに追加されたAttributeの1つであるresult_builderによって実現されています。
 ViewModel View間でのデータバインドする際に使った@Published@BindingPropertyWrapperというものです。SwiftUI, CombineフレームワークではこのPropertyWrapperも頻繁に使われているようですが、自分はあまり使ったことがなくて、まだ理解しきれてないので今後調べていきたいなと思います。

 Protocolを使って抽象化したインターフェースを実装、みたいなことがしたかったですが、
今回のViewModelの実装の場合のようにObservable@Bindingでつくったときに、それが困難になるようです。というのもViewModelOutputのプロパティが@BindingなどのPropertyWraperを使った型でつくってある場合は単純なProtocolの定義で型が解決できず、定義ができなくなります。ここが実装面においてRxSwiftと違ってるなと思いました。
いちおうPropertyWraperを考慮した型を定義するようなやり方で実装することはできるようですがシンプルに実装するのはなかなか難しそうだと思いました。
こちらの記事を参考にさせていただきました。SwiftUIとCombineを使ったMVVMの実装

最後に

SwiftUIに関しては、宣言的にUIを実装していけるのはかなり実装しやすくて良かったです。各Viewをコンポーネントとして作りやすくてかなり良いなと思いました。
Combineに関してはオペレータやデータバインドの使用感がRxSwiftと結構違っててかなり難しかったです。こちらは慣れが必要だなと感じました。
以上になりますが、SwiftUI、Combineともに、慣れればアプリ開発がより一層面白くなると思いました!

あと、やっぱりSwiftは書いてて楽しいです✌

参考にしたサイト

17
11
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
17
11

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?