[SWIFT] Exemple de modèle MVVM introduit pour éliminer FatViewController dans les grandes applications iOS

Préface

Résultats obtenus en poursuivant le développement d'applications iOS à grande échelle pendant plus de 2 ans Dans l'article ci-dessus, le modèle d'architecture n'était qu'un aperçu, nous allons donc l'examiner dans cet article.

Environnement prérequis: ・ Xcode 12.0.1 ・ Swift 5.3

Tâche

―― Le nombre d'écrans de l'application est d'environ 120 écrans. Je pense que c'est l'échelle de Sokosoko. «Nous sommes très particuliers sur la conception UI / UX, et chaque implémentation d'écran est compliquée.

Politique de résolution de problèmes

Afin d'éliminer FatViewController et de faciliter sa réparation, nous avons décidé d'introduire un modèle d'architecture.

MVVM semblait être le plus approprié pour introduire des modèles architecturaux sans changer de manière significative la construction existante. ** C'est parce que je pensais que ce serait "cool" si je découpais la logique métier du contrôleur de vue existant vers le modèle de vue et incluais la notification entre la vue et le modèle de vue. ** **

J'ai également pensé que des modèles plus complexes que MVVM, tels que Clean Architecture, ne conviendraient pas à l'équipe en termes de coûts d'apprentissage.

Lors de l'application de MVVM, nous avons considéré la politique suivante.

―― Puisqu'il y a des parties d'interface utilisateur personnalisées et une aide à la mise en page automatique que j'ai créées moi-même, j'ai pensé qu'il serait difficile de moderniser la bibliothèque, j'ai donc abandonné la liaison de données. ――A l'origine, il n'y avait pas de test unitaire, et nous avons essayé d'automatiser le test de l'interface utilisateur par XCUITest, mais le coût de maintenance du code de test lors de l'ajout ou du changement de fonctions était élevé, donc cela n'en valait pas la peine pour nous. WantJe souhaite écrire un nouveau test pour la partie logique avec XCTest, mais j'étais toujours préoccupé par les performances de coût de l'écriture du test pour la couche View, alors j'ai réduit le code de test uniquement à la partie logique et vérifié l'interface utilisateur en fonctionnement réel. J'ai décidé de le diviser.

Donc ** Nous visions à éliminer FatViewController avec la politique d'introduire l'architecture MVVM (comme?) Par nous-mêmes sans utiliser de bibliothèques. ** **

Exemple de code et bref commentaire

Ceci est un exemple d'application créé pour cet article. Je vais inclure quelques éléments pour expliquer l'architecture, mais veuillez comprendre que c'est différent du code dans la pratique.

Exemple d'application

C'est une application qui acquiert le flux RSS de Google News et l'affiche dans une liste dans UITableView. Lorsqu'une cellule est sélectionnée, les nouvelles seront affichées sur le SF SafariViewController. スクリーンショット 2020-10-19 11.48.06.png スクリーンショット 2020-10-19 12.20.22.png En frappant l'API et en attendant une réponse, le chargement sera affiché. L'affichage initial et le moment où l'UITableView est abaissé. Autrement dit, cette application ** "a un état de chargement". ** ** スクリーンショット 2020-10-19 11.48.02.png

Exemple de code de modèle

--Cette classe gère le flux RSS de Google News, qui est la zone problématique de cette application. ――Pour l'explication du flux RSS, cet article est merveilleux et facile à comprendre. - Google News Rss(API)

Model.swift


import Foundation

///Protocole qui résume le comportement de Model for DI
protocol ModelProtocol {
    func retrieveItems(completion: @escaping (Result<[Model.Article], Error>) -> Void)
    func createItems(with data: Data) -> Result<[Model.Article], Error>
}

///Responsable de la conservation des données et des procédures du domaine d'application (domaine problématique)
class Model: NSObject, ModelProtocol {
    ///Article de presse
    class Article {
        var title = ""
        var link = ""
        var pubDateStr = ""
        var pubDate: Date? {
            return createDate(from: pubDateStr)
        }
        var description = ""
        var source = ""
    }
    private var articles = [Article]()

    ///Définition de l'élément XML de Google NEWS
    enum Element: String {
        case item = "item"
        case title = "title"
        case link = "link"
        case pubDate = "pubDate"
        case description = "description"
        case source = "source"

        var name: String {
            return self.rawValue
        }
    }
    private var currentElementName : String?

    //Erreur lors de l'analyse XML
    private var parseError: Error?

    ///Obtenez le RSS de Google NEWS
    func retrieveItems(completion: @escaping (Result<[Model.Article], Error>) -> Void) {
        let url = URL(string:  "https://news.google.com/rss?hl=ja&gl=JP&ceid=JP:ja")!
        URLSession.shared.dataTask(with: url, completionHandler: { [weak self](data, response, error) in
            guard let self = self else {
                return
            }
            sleep(3)    //Délai de pseudo-réponse
            if let error = error {
                completion(Result.failure(error))
                return
            }
            guard let data = data else {
                completion(Result.success([Article]()))
                return
            }
            print("\(String(data: data, encoding: .utf8) ?? "decode error.")")    // DEBUG
            completion(self.createItems(with: data))
        }).resume()
    }

    ///Générez un éventail d'articles de presse basés sur le RSS de Google NEWS
    func createItems(with data: Data) -> Result<[Model.Article], Error> {
        let parser = XMLParser(data: data)
        parser.delegate = self
        parser.parse()
        if let parseError = parseError {
            return Result.failure(parseError)
        } else {
            return Result.success(articles)
        }
    }
}

// MARK: -Groupe de traitement d'analyseur XML
extension Model: XMLParserDelegate {
    //une analyse_Au début
    func parserDidStartDocument(_ parser: XMLParser) {
        articles.removeAll()
    }

    ///une analyse_Au début de l'élément
    func parser(_ parser: XMLParser,
                didStartElement elementName: String,
                namespaceURI: String?,
                qualifiedName qName: String?,
                attributes attributeDict: [String : String]) {

        currentElementName = nil
        if elementName == Element.item.name {
            //Générer une nouvelle classe d'article par défaut lorsque l'article d'actualité suivant apparaît
            articles.append(Article())
        } else {
            //Pour chaque élément
            currentElementName = elementName
        }
    }

    ///une analyse_Obtenez de la valeur dans l'élément
    func parser(_ parser: XMLParser, foundCharacters string: String) {
        //Écraser la mise à jour de la dernière classe d'article
        guard let lastItem = articles.last else {
            return
        }
        switch currentElementName {
        case Element.title.name:
            lastItem.title = string
        case Element.link.name:
            lastItem.link = string
        case Element.pubDate.name:
            lastItem.pubDateStr = string
        case Element.description.name:
            lastItem.description = string
        case Element.source.name:
            lastItem.source = string
        default:
            break
        }
    }

    ///une analyse_À la fin de l'élément
    func parser(_ parser: XMLParser,
                didEndElement elementName: String,
                namespaceURI: String?,
                qualifiedName qName: String?) {

        currentElementName = nil
    }

    ///une analyse_Une fois terminé
    func parserDidEndDocument(_ parser: XMLParser) {
        self.parseError = nil
    }

    ///une analyse_Lorsqu'une erreur survient
    func parser(_ parser: XMLParser, parseErrorOccurred parseError: Error) {
        self.parseError = parseError
    }
}

// MARK: -Fonction d'utilité
extension Model {
    ///Générer la date à partir de la chaîne de date Google NEWS
    static func createDate(from dateString: String) -> Date? {
        let formatter: DateFormatter = DateFormatter()
        formatter.calendar = Calendar(identifier: .gregorian)
        formatter.dateFormat = "E, d M y HH:mm:ss z"
        return formatter.date(from: dateString)
   }
}

Exemple de code ViewModel

--Une classe qui interroge Model via l'action envoyée depuis View et notifie View du résultat de la requête (statut: chargement, succès, échec).

ViewModel.swift


import Foundation

///Protocole pour notifier View que l'état d'acquisition des données a changé
protocol ViewModelDelegate: AnyObject {
    func didChange(status: Status)
}

///Statut d'acquisition des données
enum Status {
    case loading
    case loaded
    case error(String)
}

///Le rôle de communiquer des informations entre View et Model et de conserver l'état de View
class ViewModel {
    //Objets à fournir à View
    struct ViewItem {
        let title: String
        let link: String
        let source: String
        let pubDate: String?
    }
    private(set) var viewItems = [ViewItem]()

    //Objet qui gère le statut d'acquisition
    weak var delegate: ViewModelDelegate?
    private(set) var status: Status? {
        didSet {
            //Déléguez partout.didChange(:status)Si vous appelez, il y a une possibilité de fuite, alors faites-le avec didSet
            guard let status = status else {
                return
            }
            delegate?.didChange(status: status)
        }
    }

    //DI la classe Model pour les tests
    private let model: ModelProtocol
    init(model: ModelProtocol = Model()) {
        self.model = model
    }

    ///L'acquisition des données
    func load() {
        status = .loading
        model.retrieveItems { [weak self](result) in
            switch result {
            case .success(let items):
                self?.viewItems = items.map({ (article) -> ViewItem in
                    return ViewItem(title: article.title,
                                    link: article.link,
                                    source: article.source,
                                    pubDate: self?.format(for: article.pubDate))
                })
                self?.status = .loaded
            case .failure(let error):
                self?.status = .error("Erreur: \(error.localizedDescription)")
            }
        }
    }
}

// MARK: -Fonction d'utilité
extension ViewModel {
    ///Modifier la chaîne d'affichage à partir de Date
    func format(for date: Date?) -> String? {
        guard let date = date else {
            return nil
        }
        let formatter = DateFormatter()
        formatter.dateFormat = "yyyy/MM/dd HH:mm"
        formatter.timeZone = TimeZone(identifier: "Asia/Tokyo")
        formatter.locale = Locale(identifier: "en_US_POSIX")
        return formatter.string(from: date)
    }
}

Afficher un exemple de code

――C'est une soi-disant classe de couche de présentation.

ViewController.swift


import UIKit
import SafariServices

class ViewController: UIViewController {
    @IBOutlet weak var tableView: UITableView!

    private let viewModel = ViewModel()

    override func viewDidLoad() {
        super.viewDidLoad()

        tableView.dataSource = self
        tableView.delegate = self
        //Tirez et mettez à jour
        tableView.refreshControl = UIRefreshControl()
        tableView.refreshControl?.addTarget(self, action: #selector(refresh(sender:)), for: .valueChanged)

        viewModel.delegate = self
        viewModel.load()
    }
}

// MARK: -Groupe de traitement UITableView
extension ViewController: UITableViewDataSource, UITableViewDelegate {
    ///Renvoie le nombre de lignes
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return viewModel.viewItems.count
    }

    ///Renvoie la cellule
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let identifier = "TableViewCell"
        let cell = tableView.dequeueReusableCell(withIdentifier: identifier, for: indexPath)
        let item = viewModel.viewItems[indexPath.row]
        cell.textLabel?.text = item.title
        cell.detailTextLabel?.text = "[\(item.source)] \(item.pubDate ?? "")"
        return cell
    }

    ///Lors de la sélection de la cellule
    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        guard let url = URL(string: viewModel.viewItems[indexPath.row].link) else {
            return
        }
        let safariVC = SFSafariViewController.init(url: url)
        safariVC.dismissButtonStyle = .close
        self.present(safariVC, animated: true, completion: nil)
        tableView.deselectRow(at: indexPath, animated: true)
    }
}

// MARK: - ViewModelDelegate
extension ViewController: ViewModelDelegate {
    ///Traitement lorsque l'état de ViewModel change
    func didChange(status: Status) {
        switch status {
        case .loading:
            tableView.refreshControl?.beginRefreshing()
            tableView.reloadData()
        case .loaded:
            DispatchQueue.main.async { [weak self] in
                self?.tableView.refreshControl?.endRefreshing()
                self?.tableView.reloadData()
            }
        case .error(let message):
            DispatchQueue.main.async { [weak self] in
                self?.tableView.refreshControl?.endRefreshing()
            }
            print("\(message)")
        }
    }
}

// MARK: - Action
extension ViewController {
    ///Tirez UITableView pour mettre à jour
    @objc func refresh(sender: UIRefreshControl) {
        viewModel.load()
    }
}

Résultat

Nous pensons avoir obtenu des résultats dans les aspects suivants.

--FatViewController a été éliminé, ce qui facilite sa maintenance. ――En ne modifiant pas de manière significative la construction existante, l'effort de réarchitecture et le coût d'apprentissage ont été réduits. ――Bien que l'architecture ait changé, l'implémentation des détails est la même qu'avant, donc la productivité du développement de nouvelles fonctions n'a pas diminué (presque).

Dépôt

https://github.com/y-some/MVVMSample

Lien de référence

Recommended Posts

Exemple de modèle MVVM introduit pour éliminer FatViewController dans les grandes applications iOS