強欲で謙虚なツボツボ

趣味の読書の書の方

【SwiftUI + Combine】プロジェクトのベースになりそうなものを作る

概要

おそらく今後のiOSアプリの開発はSwiftUIで作成するだろうから、プロジェクトのベースになりそうなものを作成することにした。
せっかくなので標準搭載されているCombineを使ってみる。
構成はMVVMとするのが通例のようなのでModel, View, ViewModelのフォルダにファイルをまとめる。

 

目次

 

Combine

SwiftUIと同じくiOS13以降で使用可能な状態管理をするためのもの。
RxSwiftと同じ。使い方も結構似てるみたいだから好きな方を使えばいいと思った。
公式は正義という理念に従うのならばCombineの方がいいのかな。

これが一番わかりやすくて好きだった↓

qiita.com

 

その他の大事な登場人物

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の実装はできるので特に迷う必要はなさそう。

f:id:taopo:20210628110319p:plain

 

こんな構成で作成される。

f:id:taopo:20210628110636p:plain

 

AppDelegateがない

AppDelegateファイルがないですね。
その代わり、アプリ開いた時・閉じた時の検知は下のようにできる模様。
AppDelegateよりも簡明でいいと思った。

f:id:taopo:20210628110931p:plain

 

Combineを試してみる

とりあえず、以下の2つを目標に頑張ってみる。

  • 構成はMVVMを意識する
  • APIで情報を取得完了したら表示に反映させる
  • フォームでユーザ入力を監視する

 

フォルダ構成

こんな感じになった。
Views, ViewModels, Modelsを作って、リクエストやデータの構造体などは適当にModelsに詰め込めた。

f:id:taopo:20210629111434p:plain

 

APIで情報を取得完了したら表示に反映させる

よくあるQiitaの記事を取得するのをやる。

https://qiita.com/api/v2/items

Model→ViewModel→Viewの順番でファイルを作成する。

Model

Models/Entities/Article.swift

まず、データの構造体を作る。上のurlを確認するとわかるがこんなにデータは必要ないので、id, titile, urlを使う。

  • Codable:APIで取得したJSONを好きなデータ型に変換する。
  • Identifiable:リストで表示させるのに必要。
// 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メソッドを追加

f:id:taopo:20210629123451p:plain

Models/Protcols/ArticleProtcol.swift

f:id:taopo:20210629123516p:plain

Models/Requests/ArticleRequest.swift

ViewModel

ユーザ入力の監視のためにCurrentValueSubjectを用いる。
値の変更がある度に前項のsearchメソッドを呼ぶ。

  • CurrentValueSubject:一つの値を内包し、値の変更を検知して新しい値を発行する。
  • debounce:入力終了後に待つ。入力一文字ごとにリクエストしていたら大変だから。
  • removeDuplicates:変更前と後が同じ値ならやめる。

f:id:taopo:20210629124102p:plain

ViewModels/ArticleViewModel.swift

Views

適当に変更。

f:id:taopo:20210629124355p:plain

Views/ArticleView.swift

 

deeplinkの設定は簡単だった。特に説明は不要。

f:id:taopo:20210629124849p:plain

f:id:taopo:20210629124859p:plain

 

終わりに

なあなあになってたSwiftUIとCombineを自分なりにまとめられたので少しスッキリしてよかった。

よくできたものだとは言いかねるが、気づいたところを少しずつ修正したりしてプロジェクトのベース的なのにできたらいいや。
なんだかんだで、これくらいのものでも毎回一から作るのは面倒すぎる。