【SwiftUI + Combine】プロジェクトのベースになりそうなものを作る
概要
おそらく今後のiOSアプリの開発はSwiftUIで作成するだろうから、プロジェクトのベースになりそうなものを作成することにした。
せっかくなので標準搭載されているCombineを使ってみる。
構成はMVVMとするのが通例のようなのでModel, View, ViewModelのフォルダにファイルをまとめる。
目次
- 概要
- 目次
- Combine
- その他の大事な登場人物
- AppDelegateがない
- Combineを試してみる
- フォルダ構成
- APIで情報を取得完了したら表示に反映させる
- フォームでユーザ入力を監視する
- おまけ(deeplink)
- 終わりに
Combine
SwiftUIと同じくiOS13以降で使用可能な状態管理をするためのもの。
RxSwiftと同じ。使い方も結構似てるみたいだから好きな方を使えばいいと思った。
公式は正義という理念に従うのならばCombineの方がいいのかな。
これが一番わかりやすくて好きだった↓
その他の大事な登場人物
ObservableObject
ViewModelにあたるclassにはこれを継承させる。
名前の通り、値を監視してくれて変更があればViewに即時反映される。
監視する値には@Publishedをつける。
このViewModelに対応するViewで、@ObservedObject ViewModel名 と定義してあげる。
@State
これも値を監視してくれる。
表示フラグとかでよく使う。
こんな感じで使う↓
class TestViewModel: ObservableObject { @Published var aaa: String = "aaa" var bbb: String = "bbb" // APIでデータを取得してaaaに代入する、みたいなことをCombineで実装していく。 } struct TestView { var body: some View { @ObservedObject viewModel: TestViewModel = TestViewModel() @State var isShow: Bool = false if (isShow) { // isShowの切り替えで表示が変わる Text(viewModel.aaa) // aaaの値が変わるたびに表示が変わる } else { Text(viewModel.bbb) // bbbは@Publishedではないので値が変わっても表示は変わらない } } }
プロジェクトを立ち上げる
Xcode11以上が必要です。
iOSのAppプロジェクトを作成。
interfaceやLife CycleはSwiftUIを選ぶ。SwiftUIでもUIKitの実装はできるので特に迷う必要はなさそう。
こんな構成で作成される。
AppDelegateがない
AppDelegateファイルがないですね。
その代わり、アプリ開いた時・閉じた時の検知は下のようにできる模様。
AppDelegateよりも簡明でいいと思った。
Combineを試してみる
とりあえず、以下の2つを目標に頑張ってみる。
- 構成はMVVMを意識する
- APIで情報を取得完了したら表示に反映させる
- フォームでユーザ入力を監視する
フォルダ構成
こんな感じになった。
Views, ViewModels, Modelsを作って、リクエストやデータの構造体などは適当にModelsに詰め込めた。
APIで情報を取得完了したら表示に反映させる
よくあるQiitaの記事を取得するのをやる。
https://qiita.com/api/v2/items
Model→ViewModel→Viewの順番でファイルを作成する。
Model
Models/Entities/Article.swift
まず、データの構造体を作る。上のurlを確認するとわかるがこんなにデータは必要ないので、id, titile, urlを使う。
// Models/Entities/Article.swift import Foundation struct Article: Codable, Identifiable { let id: String let title: String let url: String }
一応Errorも作った。
// Models/Entities/Errors.swift import Foundation enum RequestError: Error { case parse(description: String) case network(description: String) }
Models/Entities/Protcols/ArticleProtcol.swift
プロトコルを作成。
今はQiitaの記事取得だけしかしないので、fetchメソッドを持つだけ。
Publisherをreturnする。データ取得完了や失敗のイベントを発行するみたいな感じか。
// Models/Protcols/ArticleProtcol.swift import Foundation import Combine protocol ArticleProtcol { func fetch() -> AnyPublisher<[Article], Error> }
Models/Requests/ArticleRequest.swift
APIのリクエストを司る部分。前項で作ったProtcolに準拠。
- apiComponents:APIのurlを作る。
- fetch:protcolに記述したメソッド。
- URLSession:ネットワーク上のデータ転送処理をまとめている。基本的にはsharedを使えばいい。
- map:データを整形したりできる。ログ出力すると何をしているかよくわかる。
- eraseToAnyPublisher:簡単に言うと型変換。
// Models/Requests/ArticleRequest.swift import Foundation import Combine // MARK: - Request class ArticleRequest: ArticleProtcol { private let scheme = "https" private let host = "qiita.com" private let basePath = "/api/v2" func fetch() -> AnyPublisher<[Article], Error> { guard let url = apiComponents(path: "/items").url else { let error = RequestError.parse(description: "wrong request url") return Fail(error: error).eraseToAnyPublisher() } return URLSession.shared .dataTaskPublisher(for: URLRequest(url: url)) .map({ $0.data }) .decode(type: [Article].self, decoder: JSONDecoder()) .eraseToAnyPublisher() } } // MARK: - API extension ArticleRequest { func apiComponents(path: String) -> URLComponents { var components = URLComponents() components.scheme = scheme components.host = host components.path = basePath + path return components } }
ViewModels
前項で作ったfetchメソッドを利用する。
データ取得完了や失敗を監視して
- sink:データ取得完了や失敗を監視してくれるみたい。
- cancellables:完了したらちゃんと完了したよとしてあげないといけない。メモリ管理の領域。
// ViewModels/ArticleViewModel.swift import Foundation import Combine class ArticleViewModel: ObservableObject { @Published var title: String = "Article View" @Published var articles: [Article] = [] @Published var isLoading: Bool = false private var articleRequest = ArticleRequest() private var cancellables = Set() func fetchArticles() { isLoading = true articleRequest.fetch() .receive(on: DispatchQueue.main) .sink(receiveCompletion: { completion in switch completion { case .finished: print("finished") self.isLoading = false case .failure(let error): print(error.localizedDescription) self.isLoading = false } }, receiveValue: { response in self.articles = response }) .store(in: &cancellables) } }
Views
特に説明することはない。
Listは最初にIdentifiableにしたから使える。
// Views/ArticleView.swift import SwiftUI struct ArticleView: View { @ObservedObject var viewModel: ArticleViewModel = ArticleViewModel() var body: some View { VStack() { if (viewModel.isLoading) { Text("Now Loading...") } else if (viewModel.articles.isEmpty) { Text("No results") } else { List { ForEach(viewModel.articles) { article in ArticleRowView(article: article) } } } } .onAppear { viewModel.fetchArticles() } } }
// Views/ArticleRowView.swift import SwiftUI struct ArticleRowView: View { @State private var isShowAlert: Bool = false let article: Article var body: some View { HStack { Group { Text(article.title) ZStack { Button("") { isShowAlert = true } } } }.alert(isPresented: $isShowAlert) { Alert( title: Text(""), message: Text("launch safari?"), primaryButton: .default( Text("OK"), action: { self.isShowAlert = false guard let url = URL(string: article.url) else { return } UIApplication.shared.open(url) } ), secondaryButton: .cancel(Text("Cancel")) ) } } }
// ContentView.swift import SwiftUI struct ContentView: View { var body: some View { NavigationView { VStack() { Text("Hello, world!") .padding() NavigationLink("ArticleView", destination: ArticleView()) } } } }
フォームでユーザ入力を監視する
Qiitaの記事を取得できたから、次はTextFieldで入力してタグ検索できるようにしてみる。
Modelはメソッドを追加するだけだが、ViewModelは色々追加する。
Model
タグ検索用のsearchメソッドを追加
ViewModel
ユーザ入力の監視のためにCurrentValueSubjectを用いる。
値の変更がある度に前項のsearchメソッドを呼ぶ。
- CurrentValueSubject:一つの値を内包し、値の変更を検知して新しい値を発行する。
- debounce:入力終了後に待つ。入力一文字ごとにリクエストしていたら大変だから。
- removeDuplicates:変更前と後が同じ値ならやめる。
Views
適当に変更。
おまけ(deeplink)
deeplinkの設定は簡単だった。特に説明は不要。
終わりに
なあなあになってたSwiftUIとCombineを自分なりにまとめられたので少しスッキリしてよかった。
よくできたものだとは言いかねるが、気づいたところを少しずつ修正したりしてプロジェクトのベース的なのにできたらいいや。
なんだかんだで、これくらいのものでも毎回一から作るのは面倒すぎる。