SwiftUIの実装例(MVVMパターン, CoreData, API実行+非同期処理)

# はじめに
業務で初めてSwiftUIによるiOSアプリ開発をした際に、やり方に辿り着くまでに特に苦労した以下の3つについて、簡易的な実装例と共に紹介します。

- MVVMパターン
- CoreDataによるデータ永続化
- AlamofireライブラリによるAPI実行の非同期処理

SwiftUIは比較的新しいUIフレームワークで、実装例が少なかったため、アプリでよくある基本的な処理ばかりですが、かなり試行錯誤しながら実装しました。そのため、自己流なところが多い可能性がありますが、ご了承ください。説明はかなり省略しており、ソースコードをメインにのせています。

# SwiftUIとは
SwiftUIはiOSアプリの比較的新しいUIフレームワークです。従来まではStoryboardとUIKitを使うやり方が主流で、現在でもSwiftUIでは実現できない実装も多いです。ただ、SwiftUIは宣言的なUIの記述ができ、簡単に実装することができます。業務では、プロトタイプレベルの開発であったため、SwiftUIを採用しました。（それでも、UIKitのコードを併用する必要はありました。）

```swift
// SwiftUIの宣言的な記述例
import SwiftUI

struct ContentView: View {
    var body: some View {
        VStack { // 縦にレイアウトするためのView
            Text("🥚").font(.system(size: 50))
            Text("🐥").font(.system(size: 50))
            Text("🐔").font(.system(size: 50))
            Text("🍗").font(.system(size: 50))
            Text("🎄").font(.system(size: 50))
        }
    }
}
```

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/50456/8648f6bd-4bf9-5109-fa2e-b9566947c61b.png" width="200">


# MVVMパターン
SwiftUI開発を始めて、まず最初の疑問が「データ処理等のロジックはどこに書けばいいの？」でした。あくまでパターンの1つですが、MVVMパターンと呼ばれるやり方が主流なようです。「MVVM」は、Model, View, ViewModelを指します。ModelとViewはMVCの定義と同じです。「ViewModel」は、ViewとModelの値の橋渡しをすることはControllerと同じですが、「データバインディング」の仕組みを持つことが特徴です。

ViewModelのデータバインディングとは、Viewに値をマッピングするのではなく、ViewModelが管理するデータを直接Viewが監視して画面に反映する仕組みだと私は理解しています（自信がないので、もしご指摘あればお願いします）。SwiftUIでは、ViewModelクラスのデータ監視を可能にするために「ObserbleObject」を用いることで「データバインディング」します。

例として、以下の画像のような、フォームに入力したテキストがリストに追加されていくだけのアプリをMVVMパターンで実装します。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/50456/e72be4ab-aaef-61bc-4d49-4bbf971336ec.png" width="200">

```swift
// Model (配列にデータを保存するだけの簡易的なもの)
class MessagesModel {
    private var messages: [String] = []
    
    func fetchAll() -> [String] {
        return messages
    }
    
    func save(input: String) {
        messages.append(input)
    }
}
```

```swift
// ViewModel
class ContentViewModel: ObservableObject { // データバインディングを可能にする「ObserbleObject」プロトコルに準拠
    @Published var input: String = "" // @Publishedをつけると監視対象となる
    @Published var messages: [String] = []
    
    let model = MessagesModel()
        
    func fetch() {
        messages = model.fetchAll()
    }
    
    func set() {
        model.save(input: input)
    }
    
    func clearInput() {
        input = ""
    }
}
```

```swift
// View
struct ContentView: View {
    // @StateObjectを設定すると、監視対象のプロパティが変更された時にViewが更新される。StateObjectは双方向のデータバインディングも可能。
    @StateObject var vm = ContentViewModel() 

    var body: some View {
        VStack {
            HStack {
                TextField("未入力", text: $vm.input) // テキストフォームの入力値を、ViewModelのプロパティに格納
                Button(action: {
                    vm.set() // データ保存
                    vm.fetch() // データ更新
                    vm.clearInput() // 入力値をクリア
                }){
                    Image(systemName: "plus.circle.fill")
                }
            }
            .padding()
            
            List(vm.messages, id: \.self) { message in
                Text(message)
            }
        }
    }
}
```

# CoreDataによるデータ永続化
データを保存する際に、クラウド上のデータストアではなく、スマホアプリ自体に保存したい場合があると思います。その場合、iOSアプリは「CoreData」がiOS標準で提供されています。CoreDataはデータ永続化のためのORマッパーです。データ永続化オブジェクトの実態はSQLiteのようです。（が、一次ソースが見つけれず。。） 

また、限られた値だけを保存したい場合は、「UserDefault」というキーバリュー型でデータを永続化する仕組みもあります。ただし、UserDefaultに大量のデータを格納することはアンチパターンとされており、データ量が多い場合はCoreDataを使用することになります。

## CoreDataの使い方(MVVMパターン版)
MVVMパターンで使用したMessagesModelをCoreData版に書き換えます。SwiftUIライフサイクルかつMVVMパターンを使用したCoreDataの実装方法は、あまり情報がなく実装するのに苦労しました。

- プロジェクトを作成する際に、「Use Core Data」にチェックを入れる　（後からでも追加できますが、少し面倒な作業が発生します。）

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/50456/a61b63ca-96bb-9faa-3d7a-98f96507ea91.png" width="200">

- プロジェクト名.xcdatamodeldというファイルで、データモデルを定義します。今回は、MVVMパターンの例で使用したMeesagesModelのCoreData版を実装するので、"timestamp"と"message"のプロパティを持つMessagesエンティティを下図のように定義します。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/50456/37a2a4f1-96f0-b85e-2844-d247be6498fa.png" width="200">

- Messagesデータモデルを定義後、MessageModelをCoreData版に書き換えます。xcdatamodeldファイルでデータモデルを定義すると、自動でデータモデルのクラスが作成されます。そのため、MessageModelクラスで唐突に使用しているMessagesクラスは、自動で生成されたものを使用しています。

```swift
// Model(CoreData)
import Foundation
import CoreData

class MessagesModel {
    // シングルトン化
    static let shared = MessagesModel()
    
    // CoreDataを扱うためのNSPersistentContainerクラスをインスタンス化
    private var container: NSPersistentContainer = {
        let container = NSPersistentContainer(name: "sample")
        container.loadPersistentStores { description, error in
            if let error = error {
                fatalError("永続化ストア読み込み失敗: \(error)")
            }
        }
        return container
    }()
    
    // 管理対象のオブジェクトの変更・追跡のためのオブジェクト
    private var context: NSManagedObjectContext {
        return container.viewContext
    }
    
    private init() {
    }

    func fetchAll() -> [Messages] {
        let request = NSFetchRequest<Messages>(entityName: "Messages")
        request.sortDescriptors = [NSSortDescriptor(key: "timestamp", ascending: false)]
        do {
            return try context.fetch(request)
        } catch {
            fatalError("CoreDataからデータ取得失敗")
        }
    }
    
    func save(input: String) {
        let new = Messages(context: context)
        new.timestamp = Date()
        new.message = input
        do {
            return try context.save()
        } catch {
            fatalError("データ保存失敗")
        }
    }
}
```

- ModelをCoreDataに変更したことに従って、ViewModelを以下のように変更します

```swift
// ViewModel
class ContentViewModel: ObservableObject {
    @Published var input: String = ""
    @Published var messages: [String] = []
        
    func fetch() {
        var tmpMessages:[String] = []
        let fetched = MessagesModel.fetchAll()
        if (!fetched.isEmpty) {
            for data in fetched {
                tmpMessages.append(data.message ?? "")
            }
        }
        messages = tmpMessages
    }
    
    func set() {
        MessagesModel.save(input: input)
    }
    
    func clearInput() {
        input = ""
    }
}
```

- Viewはそこまで変更点ないですが、インメモリで配列に保存していた時とは違い、画面が開かれた時にデータを取得しに行きたいのでその処理を仕込みます。

```swift
// View
struct ContentView: View {
    @StateObject var vm = ContentViewModel()

    var body: some View {
        VStack {
           // MVVMパターンの説明と同じ
        }
        .onAppear(perform: vm.fetch)
    }
}
```

# AlamofireライブラリによるAPI実行の非同期処理
Alamofireライブラリを使用したAPI実行と、それと合わせて非同期処理の実装例を紹介します。
SwiftにはCombineという標準の非同期処理のフレームワークがあり、AlamofireもCombineに則った非同期処理の実装が可能です。

Combineによる非同期処理では、「Publisher：イベント発行」と「Subscriber：イベント購読」の大きく２つの要素があります。API実行をする際には、API実行処理をPublisherとして定義し、Publisherを呼び出すときに、値を受け取った時の処理をSubscriberに定義します。

## Alamofireライブラリ+Combineの非同期処理の例
月齢を取得できるAPIを用いて、取得したデータをViewに反映し、エラーの場合には、エラーメッセージをViewに表示す画面を作ります。

月齢API
http://labs.bitmeister.jp/ohakon/index.cgi

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/50456/4cfeae9e-798d-6330-e134-11b8c0650069.png" width="200"> <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/50456/4a8989fc-18de-b7f0-85e8-2c24f285a4db.png" width="200">

```swift
// API実行クラス
import Foundation
import Alamofire
import Combine

struct Response: Decodable {
    let date : DateResponse
    let moon_age: Double
    let version: String
    
    struct DateResponse: Decodable {
        let day: String
        let month: String
        let year: String
    }
}

enum APIError: Error {
    case error(String)
}

class APIClient {
    // PublisherとしてAPI実行を定義
    static func getMoonAge() -> AnyPublisher<DataResponse<Response, Error>, Never> {
        let today = Date()
        let calendar = Calendar.current
        let d = calendar.dateComponents(
            [Calendar.Component.year, Calendar.Component.month, Calendar.Component.day],
            from: today)
        let url = "https://labs.bitmeister.jp/ohakon/json/?mode=moon_age&year=\(d.year!)&month=\(d.month!)&day=\(d.day!)"
        
        return AF.request(url, method: .get)
            .validate(statusCode: 200..<400) // 400以上のステータスコードはエラーとする
            .publishDecodable(type: Response.self) // 取得したjsonをResponse構造体にデコード
            .map { response in
                response.mapError { error in
                    // レスポンスの内容をエラーに格納
                    if (response.data != nil) {
                        let errorStr = String(data: response.data!, encoding: .utf8)!
                        return APIError.error(errorStr)
                    }
                    // APIリクエストまでいけてない場合のエラー
                    return error
                }
            }
           .eraseToAnyPublisher() // AnyPublisherでラップする
    }
}
```
```swift
// ViewModel
import Foundation
import Combine

class ContentViewModel: ObservableObject {
    @Published var moonAge: Double? = nil
    @Published var errorMessage: String = ""
    @Published var error: Bool = false
    private var cancellables = Set<AnyCancellable>() // AnyCancellableはSubscriberであるsinkの返り値
        
    func fetchByAPI() {
        APIClient.getMoonAge()
            .sink { response in // sinkでSubscriberを定義
                if response.error != nil {
                    self.moonAge = nil
                    self.errorMessage = "\(response.error!.localizedDescription)"
                    self.error = true
                } else {
                    if (response.value != nil) {
                        let data = response.value! as Response
                        self.moonAge = data.moon_age
                        self.error = false
                    }
                }
            }.store(in: &self.cancellables) // イベント購読が完了するまで、sinkの返り値を保持しておく
    }
}
```
```swift
// View
import SwiftUI

struct ContentView: View {
    @StateObject var vm = ContentViewModel()
    
    var body: some View {
        VStack {
            Button(action: {
                vm.fetchByAPI()
            }){
                HStack{
                    Image(systemName: "moon.stars.fill")
                    Text("今日の月齢は？")
                        .font(.system(size: 30))
                }
            }
            if (vm.moonAge != nil) {
                Text(String(format: "%.1f", vm.moonAge!))
                    .font(.system(size: 30))
            }
            if (vm.error) {
                Text(vm.errorMessage)
                    .foregroundColor(.red)
            }
        }
    }
}
```

# まとめ
SwiftUIの宣言的なUIの定義は、かなり魅力的な部分ですが、参考にできる情報が少なく苦労しました。開発するアプリによっては、避けた方がいい場合も多いかもしれません。ただ、今回業務でSwiftUIで開発できるようになったことで、Apple Watchアプリも同じく開発できるので、プライベートでApple Watchのアプリ開発を楽しみたいと思います！