30
17

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 3 years have passed since last update.

フューチャーAdvent Calendar 2021

Day 9

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

Last updated at Posted at 2021-12-09

はじめに

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

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

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

SwiftUIとは

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

// 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))
        }
    }
}

MVVMパターン

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

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

例として、以下の画像のような、フォームに入力したテキストがリストに追加されていくだけのアプリをMVVMパターンで実装します。

// Model (配列にデータを保存するだけの簡易的なもの)
class MessagesModel {
    private var messages: [String] = []
    
    func fetchAll() -> [String] {
        return messages
    }
    
    func save(input: String) {
        messages.append(input)
    }
}
// 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 = ""
    }
}
// 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」にチェックを入れる (後からでも追加できますが、少し面倒な作業が発生します。)
  • プロジェクト名.xcdatamodeldというファイルで、データモデルを定義します。今回は、MVVMパターンの例で使用したMeesagesModelのCoreData版を実装するので、"timestamp"と"message"のプロパティを持つMessagesエンティティを下図のように定義します。
  • Messagesデータモデルを定義後、MessageModelをCoreData版に書き換えます。xcdatamodeldファイルでデータモデルを定義すると、自動でデータモデルのクラスが作成されます。そのため、MessageModelクラスで唐突に使用しているMessagesクラスは、自動で生成されたものを使用しています。
// 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を以下のように変更します
// 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はそこまで変更点ないですが、インメモリで配列に保存していた時とは違い、画面が開かれた時にデータを取得しに行きたいのでその処理を仕込みます。
// 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:イベント購読」の大きく2つの要素があります。API実行をする際には、API実行処理をPublisherとして定義し、Publisherを呼び出すときに、値を受け取った時の処理をSubscriberに定義します。

Alamofireライブラリ+Combineの非同期処理の例

月齢を取得できるAPIを用いて、取得したデータをViewに反映し、エラーの場合には、エラーメッセージをViewに表示す画面を作ります。

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

// 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でラップする
    }
}
// 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の返り値を保持しておく
    }
}
// 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のアプリ開発を楽しみたいと思います!

30
17
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
30
17

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?