3
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

[Swift] MVCパターンを使ってコードを書く際の基本について勉強してみた!

Posted at

書いたコードの保守性を持たせテストや修正をしやすくするために必要なMVCパターンについて勉強していきます。それと合わせ単方向データーフローという概念についても勉強していきます。

・MVCの概要

MVCとは(Model-View-Controller)の略です。

Model: データやビジネスロジックを管理します。アプリケーションの状態を保持し、データの処理や計算を行います。

View: ユーザーインターフェースを管理します。ユーザーからの入力を受け取り、表示を更新します。UIViewやUIViewControllerのサブクラスです。

Controller: ModelとViewの間を仲介します。ユーザーからの入力を受け取り、Modelを更新し、Viewを更新します。

まとめると、Modelはデータの構造や操作を担当します。ViewはUIを担当し、UIViewやUIViewControllerがこれに該当します。ControllerはViewとModelの間の仲介役で、ViewControllerがこれを担います。

・MVCパターンでコードを書くメリット

・ViewController内で直接データを操作するのではなく、サービスクラスを使ってデータの取得や操作を行うと、コードの再利用性が高まります。

・ModelはできるだけUIに依存しない形にすることで、テストや保守がしやすくなります。

・単方向データフロー

単方向データフローとは、データの流れが一方向にのみ進むアーキテクチャのスタイルです。これにより、データの変化を追跡しやすくなり、バグを減らし、コードの理解と保守を容易にします。

・単方向データフローの基本概念

データの流れ:
データは一方向に流れます。通常、ユーザーインターフェース(View)からアクションが発生し、これがControllerを通じてModelを更新し、最終的にModelの変化がViewに反映されます。

View:
ユーザーが操作を行うと、アクションがトリガーされます。ViewはControllerに対して何らかの操作を指示します。

Controller:
ユーザーのアクションを受け取り、Modelに対してデータの変更を要求します。

Model:
データの状態を更新し、その変更を通知します。

View:
Modelの変更を受け取り、UIを更新します。

単方向データフローでは、データの変更が一方向に流れるためデータの状態管理がシンプルになります。ViewControllerはModelのデリゲートとして機能し、Modelの変更を監視し、必要に応じてUIを更新します。Modelはデータの変更を管理し、変更が発生するとデリゲートメソッドを呼び出して通知します。

・単方向データフローのメリット

予測可能性:
データの流れが一方向なので、どこでデータが変更されたかを追跡しやすくなります。

デバッグが容易:
変更の発生する箇所が明確であるため、バグの原因を特定しやすくなります。

コードの分離:
ViewとModelがしっかり分離されるため、コードの再利用性とテストのしやすさが向上します。

・実践

ここから実際に自分の書いたコード使って、MVCパターンを使った際の書き方を勉強していきます。

元のコード

サンプルにしたのは、SwiftDataを管理するClassと、ワード検索しSwiftDataからデータを取得しTextをView表示するViewControllerのClassです。

ViewController

import UIKit
import SwiftData

class ViewController: UIViewController {
    
    var container = SetContainer().container
    
    var textField: UITextField!
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        view.backgroundColor = .white
        
        container = CreateContainer().container
        
        showTextField()
        showSearch()
        closeButton()
    }
    
    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        view.endEditing(true)
    }
    
    //    テキストフィールド
    func showTextField() {
        let textField = UITextField()
        textField.attributedPlaceholder = NSAttributedString(string: "名前を入力",attributes: [NSAttributedString.Key.foregroundColor: UIColor.gray])
        textField.textColor = .black
        textField.layer.borderWidth = 1
        textField.layer.borderColor = UIColor.black.cgColor
        textField.layer.cornerRadius = 5
        //        border線と文字との距離を設定
        textField.leftView = UIView(frame: .init(x: 0, y: 0, width: 5, height: 0))
        textField.leftViewMode = .always
        textField.font = .systemFont(ofSize: 18)
        
        let textFieldWidth: CGFloat = 300
        let textFieldHeight: CGFloat = 30
        
        self.view.addSubview(textField)
        
        textField.translatesAutoresizingMaskIntoConstraints = false
        
        textField.widthAnchor.constraint(equalToConstant: textFieldWidth).isActive = true
        textField.heightAnchor.constraint(equalToConstant: textFieldHeight).isActive = true
        textField.centerXAnchor.constraint(equalTo: view.centerXAnchor).isActive = true
        textField.centerYAnchor.constraint(equalTo: view.centerYAnchor).isActive = true
        
        self.textField = textField
    }
    //    検索ボタン
    func showSearch() {
        let button = UIButton(type: .system)
        button.setTitle("検索", for: .normal)
        button.contentHorizontalAlignment = .center
        button.setTitleColor(.black, for: .normal)
        
        button.addTarget(self, action: #selector(addButton), for: .touchUpInside)
        
        let buttonWidth: CGFloat = 100
        let buttonHeight: CGFloat = 100
        
        self.view.addSubview(button)
        
        button.translatesAutoresizingMaskIntoConstraints = false
        
        button.widthAnchor.constraint(equalToConstant: buttonWidth).isActive = true
        button.heightAnchor.constraint(equalToConstant: buttonHeight).isActive = true
        button.centerXAnchor.constraint(equalTo: view.centerXAnchor).isActive = true
        button.topAnchor.constraint(equalTo: textField.bottomAnchor, constant: 50).isActive = true
        
    }
    
    @objc func addButton() {
        if let text = setTextField.text {
            getUserSd(for: text)
        }
    }
    
    func closeButton() {
        let closeButton = UIButton()
        closeButton.setTitle("ページを閉じる", for: .normal)
        closeButton.contentHorizontalAlignment = .center
        closeButton.setTitleColor(.blue, for: .normal)
        
        closeButton.addTarget(self, action: #selector(addClose), for: .touchUpInside)
        
        let closeWidth: CGFloat = 200
        let closeHeight: CGFloat = 100
        
        self.view.addSubview(closeButton)
        
        closeButton.translatesAutoresizingMaskIntoConstraints = false
        
        closeButton.widthAnchor.constraint(equalToConstant: closeWidth).isActive = true
        closeButton.heightAnchor.constraint(equalToConstant: closeHeight).isActive = true
        closeButton.centerXAnchor.constraint(equalTo: view.centerXAnchor).isActive = true
        closeButton.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -100).isActive = true
        
    }
    
    @objc func addClose() {
        self.dismiss(animated: true, completion: nil)
    }
    //    ラベル
    func showLabel(for name: String, seatNumber: Int) {
        let label = UILabel()
        label.text = "名前: \(name)\n 座席番号: \(seatNumber)"
        label.textAlignment = NSTextAlignment.center
        label.numberOfLines = 0
        label.font = .systemFont(ofSize: 30)
        label.textColor = .black
        
        self.view.addSubview(label)
        
        label.translatesAutoresizingMaskIntoConstraints = false
        
        label.centerXAnchor.constraint(equalTo: view.centerXAnchor).isActive = true
        label.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 100).isActive = true
    }
    //     SwiftDataの取得
    func getUserSd(for setName: String) {
        do {
            //     #Predicateマクロを使用して、述語を構築
            //     UserNameに該当するDataを取得する
            let userName = #Predicate<User> {$0.name == setName}
            /*フェッチの基準を定義したらFetchDescriptorを使用して、
             モデルコンテキストにデータを取得するように指示
             */
            let descriptor = FetchDescriptor<User>(predicate: userName)
            //            コンテナのコンテキストの呼び出し
            guard let userData = try container?.mainContext.fetch(descriptor) else {
                //                エラーが発生した場合の処理
                print("SwiftData[User] Error: データの取得に失敗しました.")
                return
            }
            print(userData)
            //            名前に紐付いた取得できた場合の処理
            //            名前を取得
            guard let name = userData.first?.name else {
                //                エラーが発生した場合の処理
                print("Get Name Error: ユーザー名が見つかりません.")
                return
            }
            print(name)
            //            ユニークIDを取得する
            guard let id = userData.first?.uuid else {
                //                エラーが発生した場合の処理
                print("Get UUID Error: ユニークIDが見つかりません.")
                return
            }
            print(id)
            //            取得できた場合の処理
            getNumberSd(for: name, uuid: id)
        } catch {
            //            エラーが発生した場合の処理
            print("SwiftData[User] Error: 予想しないErrorが発生しました.\(error)")
        }
    }
    
    func getNumberSd(for name: String, uuid: UUID) {
        do {
            /* #Predicateマクロを使用して述語を構築
             UUIDに該当するDataを取得する
             */
            let userId = #Predicate<Number> {$0.uuid == uuid}
            /*フェッチの基準を定義したらFetchDescriptorを使用して、
             モデルコンテキストにデータを取得するように指示
             */
            let descriptor = FetchDescriptor<Number>(predicate: userId)
            
            guard let id = try container?.mainContext.fetch(descriptor) else {
                //                エラーが発生した場合の処理
                print("SwiftData[Number] Error: データの取得に失敗しました.")
                return
            }
            print(id)
            //            idに紐付いた取得できた場合の処理
            //            idに紐付いたseatNumberを取得する
            guard let seatNumber = id.first?.seatNumber else {
                //            エラーが発生した場合の処理
                print("Get seatNumber Error: シートナンバーが見つかりません.")
                
                return
            }
            print(seatNumber)
            //            取得できた場合の処置
            showLabel(for: name, seatNumber: seatNumber)
            self.textField.text = ""
            
        } catch {
            print("SwiftData[Number] Erro: 予想しないErrorが発生しました.\(error)")
        }
    }
    
}

SwiftData

import Foundation
import SwiftData

class SetContainer {
    //    モデルコンテナの変数
    var container: ModelContainer?
}

class CreateContainer {
    //    モデルコンテナの作成
    var container: ModelContainer = {
        //        スキーマの定義
        let schema = Schema([
            User.self,
            Number.self
        ])
        //        モデルコンテナの構成を設定
        let modelConfiguretion = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false)
        
        do {
            //            モデルコンテナを設定して返す
            return try ModelContainer(for: schema, configurations: [modelConfiguretion])
        } catch {
            //            エラーが発生した場合は致命的なエラーを発生させる
            fatalError("Could not create ModelContainer (ModelContainer を作成できませんでした): \(error)")
        }
    }()
    //    モデルコンテナを初期化する
    func initializeModelContainer() -> ModelContainer {
        //        スキーマの定義
        let schema = Schema([
            User.self,
            Number.self
        ])
        //        モデルコンテナの構成を設定
        let modelConfiguretion = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false)
        
        do {
            //            モデルコンテナを設定して返す
            return try ModelContainer(for: schema, configurations: [modelConfiguretion])
        } catch {
            //            エラーが発生した場合は致命的なエラーを発生させる
            fatalError("Could not create ModelContainer (ModelContainer を作成できませんでした): \(error)")
        }
    }
}

@Model
class User {
    var name: String
    var uuid: UUID
    
    init(name: String, uuid: UUID) {
        self.name = name
        self.uuid = uuid
    }
}

@Model
class Number {
    var seatNumber: Int
    var uuid: UUID
    
    init(seatNumber: Int, uuid: UUID) {
        self.seatNumber = seatNumber
        self.uuid = uuid
    }
}

・MVCパターンで書き直したコード

元のコードは、ViewControllerがデータの取得やUIの更新、ユーザーアクションの処理をすべて行っています。MVCパターンを適用することで、これらの責任を分けて管理しやすく修正していきます。

モデル(Model)

Core Dataを使ったモデルを定義します。現在のコードではUserとNumberのSwiftDataのクラスがモデルに相当します。


import Foundation
import SwiftData

class SetContainer {
    //    モデルコンテナの変数
    var container: ModelContainer?
}

class CreateContainer {
    //    モデルコンテナの作成
    var container: ModelContainer = {
        //        スキーマの定義
        let schema = Schema([
            User.self,
            Number.self
        ])
        //        モデルコンテナの構成を設定
        let modelConfiguretion = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false)
        
        do {
            //            モデルコンテナを設定して返す
            return try ModelContainer(for: schema, configurations: [modelConfiguretion])
        } catch {
            //            エラーが発生した場合は致命的なエラーを発生させる
            fatalError("Could not create ModelContainer (ModelContainer を作成できませんでした): \(error)")
        }
    }()
    //    モデルコンテナを初期化する
    func initializeModelContainer() -> ModelContainer {
        //        スキーマの定義
        let schema = Schema([
            User.self,
            Number.self
        ])
        //        モデルコンテナの構成を設定
        let modelConfiguretion = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false)
        
        do {
            //            モデルコンテナを設定して返す
            return try ModelContainer(for: schema, configurations: [modelConfiguretion])
        } catch {
            //            エラーが発生した場合は致命的なエラーを発生させる
            fatalError("Could not create ModelContainer (ModelContainer を作成できませんでした): \(error)")
        }
    }
}

@Model
class User {
    var name: String
    var uuid: UUID
    
    init(name: String, uuid: UUID) {
        self.name = name
        self.uuid = uuid
    }
}

@Model
class Number {
    var seatNumber: Int
    var uuid: UUID
    
    init(seatNumber: Int, uuid: UUID) {
        self.seatNumber = seatNumber
        self.uuid = uuid
    }
}
データ管理クラス(Model管理クラス)

データの取得や保存を管理するクラスを作成します。これは、データの操作を行う部分です。


import Foundation
import SwiftData

class DataManager {
    static let shared = DataManager()
    var container: ModelContainer?
    
    init() {
        container = CreateContainer().container
    }
    /*
     mainContextがメインスレッドでのみアクセスされるべきプロパティであるため、
     @MainActor を使用してメインスレッドでの実行を保証します.
     */
    @MainActor func FetchUser(byName name: String, completion: @escaping (User?) -> Void) {
        do {
            //     #Predicateマクロを使用して、述語を構築
            //     nameに該当するDataを取得する
            let predicate = #Predicate<User> {$0.name == name}
            /*フェッチの基準を定義したらFetchDescriptorを使用して、
             モデルコンテキストにデータを取得するように指示
             */
            let descriptor = FetchDescriptor<User>(predicate: predicate)
            //            コンテナのコンテキストの呼び出し
            let userData = try container?.mainContext.fetch(descriptor)
            completion(userData?.first)
        } catch {
            //            エラーが発生した場合の処理
            print("Fetch User Error:\(error)")
            completion(nil)
        }
    }
    /*
     mainContextがメインスレッドでのみアクセスされるべきプロパティであるため、
     @MainActor を使用してメインスレッドでの実行を保証します.
     */
    @MainActor func FetchNumber(byUUID uuid: UUID, completion: @escaping (Number?) -> Void) {
        do {
            /* #Predicateマクロを使用して述語を構築
             UUIDに該当するDataを取得する
             */
            let predicate = #Predicate<Number> {$0.uuid == uuid}
            /*フェッチの基準を定義したらFetchDescriptorを使用して、
             モデルコンテキストにデータを取得するように指示
             */
            let descriptor = FetchDescriptor<Number>(predicate: predicate)
            
            let numbers = try container?.mainContext.fetch(descriptor)
            completion(numbers?.first)
        } catch {
            print("Fetch Number Error: \(error)")
            completion(nil)
        }
    }
    
}
ViewControllerのコード

DataManager を使用して User と Number をフェッチし、結果に基づいて UI を更新します。


import UIKit
import SwiftData

class SecondViewController: UIViewController {
    
    var textField: UITextField!
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        view.backgroundColor = .white
        
        showTextField()
        showSearch()
        closeButton()
    }
    
    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        view.endEditing(true)
    }
    
    //    テキストフィールド
    func showTextField() {
        let textField = UITextField()
        textField.attributedPlaceholder = NSAttributedString(string: "名前を入力",attributes: [NSAttributedString.Key.foregroundColor: UIColor.gray])
        textField.textColor = .black
        textField.layer.borderWidth = 1
        textField.layer.borderColor = UIColor.black.cgColor
        textField.layer.cornerRadius = 5
        //        border線と文字との距離を設定
        textField.leftView = UIView(frame: .init(x: 0, y: 0, width: 5, height: 0))
        textField.leftViewMode = .always
        textField.font = .systemFont(ofSize: 18)
        
        let textFieldWidth: CGFloat = 300
        let textFieldHeight: CGFloat = 30
        
        self.view.addSubview(textField)
        
        textField.translatesAutoresizingMaskIntoConstraints = false
        
        textField.widthAnchor.constraint(equalToConstant: textFieldWidth).isActive = true
        textField.heightAnchor.constraint(equalToConstant: textFieldHeight).isActive = true
        textField.centerXAnchor.constraint(equalTo: view.centerXAnchor).isActive = true
        textField.centerYAnchor.constraint(equalTo: view.centerYAnchor).isActive = true
        
        self.textField = textField
    }
    //    検索ボタン
    func showSearch() {
        let button = UIButton(type: .system)
        button.setTitle("検索", for: .normal)
        button.contentHorizontalAlignment = .center
        button.setTitleColor(.black, for: .normal)
        
        button.addTarget(self, action: #selector(addButton), for: .touchUpInside)
        
        let buttonWidth: CGFloat = 100
        let buttonHeight: CGFloat = 100
        
        self.view.addSubview(button)
        
        button.translatesAutoresizingMaskIntoConstraints = false
        
        button.widthAnchor.constraint(equalToConstant: buttonWidth).isActive = true
        button.heightAnchor.constraint(equalToConstant: buttonHeight).isActive = true
        button.centerXAnchor.constraint(equalTo: view.centerXAnchor).isActive = true
        button.topAnchor.constraint(equalTo: textField.bottomAnchor, constant: 50).isActive = true
        
    }
    
    @objc func addButton() {
//        テキストフィールドに入力されたテキストを取得
        if let text = textField.text {
//            Datamanagerのsharedインスタンスを使って入力された名前に対応するユーザーを取得
            DataManager.shared.FetchUser(byName: text) { [weak self] (user: User?) in
//                ユーザーが見つからなかった場合
                guard let user = user else {
                    print("Usr not found")
                    return
                }
//                見つかったユーザーのUUIDを使って番号を取得
                DataManager.shared.FetchNumber(byUUID: user.uuid) { [weak self] (number: Number?) in
//                     番号が見つからなかった場合
                    guard let number = number else {
                        print("Number not found")
                        return
                    }
//                    メインスレッドでUI更新行う
                    DispatchQueue.main.async {
//                        ラベルにユーザー名と座席番号を取得
                        self?.showLabel(for: user.name, seatNumber: number.seatNumber)
//                        テキストフィールドのテキストをクリア
                        self?.textField.text = ""
                    }
                }
            }
        }
    }
    
    func closeButton() {
        let closeButton = UIButton()
        closeButton.setTitle("ページを閉じる", for: .normal)
        closeButton.contentHorizontalAlignment = .center
        closeButton.setTitleColor(.blue, for: .normal)
        
        closeButton.addTarget(self, action: #selector(addClose), for: .touchUpInside)
        
        let closeWidth: CGFloat = 200
        let closeHeight: CGFloat = 100
        
        self.view.addSubview(closeButton)
        
        closeButton.translatesAutoresizingMaskIntoConstraints = false
        
        closeButton.widthAnchor.constraint(equalToConstant: closeWidth).isActive = true
        closeButton.heightAnchor.constraint(equalToConstant: closeHeight).isActive = true
        closeButton.centerXAnchor.constraint(equalTo: view.centerXAnchor).isActive = true
        closeButton.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -100).isActive = true
        
    }
    
    @objc func addClose() {
        self.dismiss(animated: true, completion: nil)
    }
    //    ラベル
    func showLabel(for name: String, seatNumber: Int) {
        let label = UILabel()
        label.text = "名前: \(name)\n 座席番号: \(seatNumber)"
        label.textAlignment = NSTextAlignment.center
        label.numberOfLines = 0
        label.font = .systemFont(ofSize: 30)
        label.textColor = .black
        
        self.view.addSubview(label)
        
        label.translatesAutoresizingMaskIntoConstraints = false
        
        label.centerXAnchor.constraint(equalTo: view.centerXAnchor).isActive = true
        label.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 100).isActive = true
        
    }
    
}

fetchUser(byName:) で名前を基に User エンティティを検索し、最初の結果を返します。検索された User の uuid を基に fetchNumber(byUUID:) で Number エンティティを検索し、最初の結果を返します。検索された Number の seatNumber を表示します。
これにより、元のコードと同じ手順でデータが取得され、UIが更新されることを確認できます。

DataManagerの共有インスタンスを使って、入力された名前に対応するユーザーを非同期で取得します。クロージャ内でselfが強参照にならないように、[weak self]を使っています。

DataManager.shared.FetchUser(byName: text) { [weak self] (user: User?) in

}

 DataManager.shared.FetchNumber(byUUID: user.uuid) { [weak self] (number: Number?) in
 
}

・おわりに

これはあくまで基本的なMVCパターンの使い方で、さらに複雑なアプリケーションでは、Model-View-ViewModel(MVVM)パターンなど、他のアーキテクチャが必要な場合もでてくるので引き続き勉強していこうと思います。

3
3
0

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
3
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?