はじめに
業務で初めて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のアプリ開発を楽しみたいと思います!