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
―― 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.
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. ** **
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.
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.
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". ** **
--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)
}
}
--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).
ViewModelDelegate
est utilisé pour notifier la vue du résultat de la requête (statut).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)
}
}
――C'est une soi-disant classe de couche de présentation.
ViewModelDelegate
, vous recevrez une notification du résultat de la requête (statut) et le dessinerez à l'écran. et ʻUITableViewDelegate
sont implémentés en tant que "Nari".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()
}
}
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).
https://github.com/y-some/MVVMSample