Создание iOS-приложения Todo List с архитектурой VIPER

Решение о том, какую архитектуру приложения использовать при создании приложения iOS, является одной из самых сложных задач, существует множество архитектур на выбор: MVC, MVVM, MVP, View State, VIPER и многие другие. Выбранная нами архитектура будет определять, как будет создаваться программное обеспечение и масштабироваться по мере его роста.

Одна из архитектур, о которой пойдет речь в этой статье, — это архитектура VIPER. VIPER делит структуру приложения на компоненты внутри модуля/экрана по принципу единой ответственности. Это делает приложение более модульным и менее связанным с другими компонентами. Модульное тестирование и интеграционное тестирование становятся намного проще из-за границ (протокола/интерфейса) между каждым компонентом.

Основные компоненты VIPER разделены на 5 частей:

  1. View: показывает пользовательский интерфейс, рассказает о нем presenter, а также передает вводимые пользователем данные в presenter.
  2. Interactor: обрабатывает бизнес-логику приложения, обменивается данными с presenter.
  3. Presenter: Получает данные из интерактора и обработывает логику того, как данные будут отображаться в View. Он также передает пользовательский ввод из View и извлекает/обновляет данные из Interactor.
  4. Entity: Объект модели, используемый интерактором. Обычно интерактор извлекает сущность из отдельного объекта хранилища данных.
  5. Routing/Wireframe: обработка логики навигации, запрашиваемой объектом-презентатором. Он связывается с другим модулем/экраном для отображения.

Создание нашего приложения Todo List с VIPER

В этой статье мы создадим простое приложение TodoList, используя VIPER в качестве архитектуры нашего приложения. Репозиторий проекта на GitHub доступен здесь. Что будем строить:

  1. Сущность TodoItem и TodoStore: TodoItem — это базовый объект класса, который представляет элемент Todo, TodoStore — это наше хранилище данных, в котором хранится массив TodoItem.
  2. Модуль/экран TodoList: отображение списка TodoItem в UITableView для пользователей и предоставление пользователям функций для добавления нового TodoItem, удаления TodoItem и перехода к модулю/экрану TodoDetail.
  3. Модуль/экран TodoDetail: отображение содержимого TodoItem, предоставление пользователям функций для удаления и редактирования TodoItem. Он возвращается к модулю/экрану TodoList.
  4. Интеграция App Delegate: настройте корневой UIViewController приложения, создав экземпляр TodoListView из TodoListRouter.

Создание объекта данных

Сущность TodoItem

TodoItem Entity — это просто класс, представляющий объект TodoItem. Он предоставляет 2 свойства: строку заголовка и строку содержимого.

import Foundation

class TodoItem {
    
    var title: String
    var content: String
    
    init(title: String, content: String) {
        self.title = title
        self.content = content
    }
}

TodoStore Хранилище данных

TodoStore — это объект DataStore Singleton, в котором хранится список TodoItem. Наше приложение просто хранит массив в памяти, но в будущем мы можем расширить его, чтобы хранить данные в файле или CoreData. Он предоставил массив TodoItem через свойство todos и методы для добавления TodoItem и удаления TodoItem.

class TodoStore {
    
    private init() {}
    public static let shared = TodoStore()
    
    public private(set) var todos: [TodoItem] = [
        TodoItem(title: "Focus", content: "Decide on what you want to focus in your life"),
        TodoItem(title: "Value", content: "Decide on what values are meaningful in your life"),
        TodoItem(title: "Action", content: "Decide on what you should do to achieve empowering life")
    ]
    
    func addTodo(_ todo: TodoItem) {
        todos.append(todo)
    }
    
    func removeTodo(_ todo: TodoItem) {
        if let index = todos.firstIndex(where: { $0 === todo }) {
            todos.remove(at: index)
        }
    }   
}

Создание TodoListModule/Screen

Протоколы TodoListModule

Мы используем протокол для каждого компонента, чтобы определить границы того, как каждый компонент будет взаимодействовать в модуле TodoList.

import UIKit

protocol TodoListViewProtocol: class {
    
    var presenter: TodoListPresenterProtocol? { get set }
    
    // PRESENTER -> VIEW
    func showTodos(_ todos: [TodoItem])
    func showErrorMessage(_ message: String)
}

protocol TodoListPresenterProtocol: class {
    
    var view: TodoListViewProtocol? { get set }
    var interactor: TodoListInteractorInputProtocol? { get set }
    var router: TodoListRouterProtocol? { get set }
    
    // VIEW -> PRESENTER
    func viewWillAppear()
    func showTodoDetail(_ Todo: TodoItem)
    func addTodo(_ todo: TodoItem)
    func removeTodo(_ todo: TodoItem)
}

protocol TodoListInteractorInputProtocol: class {
    
    var presenter: TodoListInteractorOutputProtocol? { get set }
    
    // PRESENTER -> INTERACTOR
    func retrieveTodos()
    func saveTodo(_ todo: TodoItem)
    func deleteTodo(_ todo: TodoItem)
}

protocol TodoListInteractorOutputProtocol: class {
    
    // INTERACTOR -> PRESENTER
    func didAddTodo(_ todo: TodoItem)
    func didRemoveTodo(_ todo: TodoItem)
    func didRetrieveTodos(_ todos: [TodoItem])
    func onError(message: String)
}

protocol TodoListRouterProtocol: class {
    
    static func createTodoListModule() -> UIViewController
    
    // PRESENTER -> ROUTER
    func presentToDoDetailScreen(from view: TodoListViewProtocol, for todo: TodoItem)
}

Реализовать TodoListViewProtocol

Мы создаем объект TodoListViewController, который является подклассом UITableViewController, и реализуем TodoListViewProtocol. Ответственность TodoListViewController заключается в отображении пользовательского интерфейса в соответствии с указаниями presenter. Он хранит ссылку на presenter для передачи пользовательского ввода и просмотра события жизненного цикла presenter для реагирования.

Когда View появится, оно вызовет метод viewWillAppear презентатора, чтобы презентатор мог получить данные из интерактора. Элемент кнопки панели навигации инициирует действие, которое отобразит UIAlertActionController с двумя текстовыми полями, чтобы пользователь мог ввести заголовок и содержимое TodoItem. Затем он передает пользовательский ввод обратно докладчику. Когда пользователь проводит пальцем по UITableViewCell и удаляет строку, представление перенаправляет ввод пользователя, чтобы удалить связанный объект ToDoItem, обратно ведущему.

TodoListViewProtocol предоставляет 2 метода для реализации, showTodos, которые передают массив ToDoItem, который будет использоваться для отображения списка TodoItem внутри UITableView. ShowErrorMessage передает сообщение об ошибке. В случае возникновения ошибки от презентатора пользователю будет отображаться UIAlertController, содержащий сообщение об ошибке.

import UIKit

class TodoListViewController: UITableViewController {
    
    var presenter: TodoListPresenterProtocol?
    var todos: [TodoItem] = [] {
        didSet {
            tableView.reloadData()
        }
    }

    override func viewDidLoad() {
        super.viewDidLoad()
        setupView()
    }
    
    override func viewWillAppear(_ animated: Bool) {
        presenter?.viewWillAppear()
    }

    
    private func setupView() {
        tableView.tableFooterView = UIView()
    }
    
    override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return todos.count
    }
    
    override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath)
        let todo = todos[indexPath.row]
        cell.textLabel?.text = todo.title
        cell.detailTextLabel?.text = todo.content
        return cell
    }
    
    override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        let todo = todos[indexPath.row]
        presenter?.showTodoDetail(todo)
    }
    
    override func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCell.EditingStyle, forRowAt indexPath: IndexPath) {
        if editingStyle == .delete {
            let todoItem = todos[indexPath.row]
            presenter?.removeTodo(todoItem)
        }
    }
    
    @IBAction func addTapped(_ sender: Any) {
        let alertController = UIAlertController(title: "Add Todo Item", message: "Enter title and content", preferredStyle: .alert)
        alertController.addTextField(configurationHandler: nil)
        alertController.addTextField(configurationHandler: nil)
        alertController.addAction(UIAlertAction(title: "Confirm", style: .default, handler: { [weak self](_) in
            let titleText = alertController.textFields![0].text ?? ""
            let contentText = alertController.textFields![1].text ?? ""
            guard !titleText.isEmpty else { return }
            let todoItem = TodoItem(title: titleText, content: contentText)
            self?.presenter?.addTodo(todoItem)
        }))
        
        alertController.addAction(UIAlertAction(title: "Cancel", style: .cancel, handler: nil))
        present(alertController, animated: true, completion: nil)
    }
    
}

extension TodoListViewController: TodoListViewProtocol {
    
    func showTodos(_ todos: [TodoItem]) {
        self.todos = todos
    }
    
    func showErrorMessage(_ message: String) {
        let alertController = UIAlertController(title: "Error", message: message, preferredStyle: .alert)
        alertController.addAction(UIAlertAction(title: "OK", style: .default, handler: nil))
        present(alertController, animated: true, completion: nil)
    }
    
}

Реализовать TodoListPresenterProtocol

Класс TodoListPresenter реализует TodoListPresenterProtocol и TodoListInteractorOutputProtocol. Он хранит слабую ссылку на TodoListViewProtocol, чтобы он мог обновлять пользовательский интерфейс. Он хранит ссылку на TodoListInteractorInputProtocol, поэтому ведущий может передать пользовательский ввод для получения или изменения данных через интерактор. Он также хранит объект TodoListRouterProtocol, чтобы он мог перейти к TodoDetailModule, когда пользователь выбирает TodoItem в table View.

При реализации TodoPresenterProtocol View будет вызывать view презентатора viewWillAppear, когда view появится на экране. Затем presenter просит интерактора получить массив TodoItem. Реализация TodoListInteractorOutputProtocol будет использоваться интерактором для возврата массива TodoItem путем вызова didRetrieveTodos, передающего данные, затем презентатор обновляет view, вызывая представление showTodos, передающее данные для обновления table View.

Presenter также реализует didAddTodo и didRemoveToDo, поэтому, когда пользователь добавляет новый элемент TodoItem или удаляет элемент TodoItem из view, presenter может передать действие пользователя интерактору, соответствующему методам saveTodo и deleteTodo. Интерактор вызовет didAddTodo и didRemoveTodo обратно к presenter, чтобы presenter мог обновить table View.

import UIKit

class TodoListPresenter: TodoListPresenterProtocol {
    
    weak var view: TodoListViewProtocol?
    var interactor: TodoListInteractorInputProtocol?
    var router: TodoListRouterProtocol?
    
    func showTodoDetail(_ Todo: TodoItem) {
        guard let view = view else { return }
        router?.presentToDoDetailScreen(from: view, for: Todo)
    }
    
    func addTodo(_ todo: TodoItem) {
        interactor?.saveTodo(todo)
    }
    
    func viewWillAppear() {
        interactor?.retrieveTodos()
    }
    
    func removeTodo(_ todo: TodoItem) {
        interactor?.deleteTodo(todo)
    }
    
}

extension TodoListPresenter: TodoListInteractorOutputProtocol {
    
    func didAddTodo(_ todo: TodoItem) {
        interactor?.retrieveTodos()
    }
    
    func didRetrieveTodos(_ todos: [TodoItem]) {
        view?.showTodos(todos)
    }
    
    func onError(message: String) {
        view?.showErrorMessage(message)
    }
    
    func didRemoveTodo(_ todo: TodoItem) {
        interactor?.retrieveTodos()
    }
}

Реализовать TodoListInteractorProtocol

TodoListInteractor реализует TodoListInteractorInputProtocol. Он хранит ссылку на объект презентатора, который противоречит TodoListInteractorOutputProtocol. Он также имеет объект TodoStore, назначенный в качестве свойства для извлечения списка TodoItem, добавления TodoItem, удаления TodoItem из TodoStore.

TodoListPresenterProtocol вызывает действие, чтобы получить список TodoItem, сохранить TodoItem, удалить TodoItem, интерактор, затем запросить TodoStore для каждого соответствующего действия, а затем вызвать метод вывода обратно ведущему.

import Foundation

class TodoListInteractor: TodoListInteractorInputProtocol {
    
    weak var presenter: TodoListInteractorOutputProtocol?
    var todoStore = TodoStore.shared
    var todos: [TodoItem] {
        return todoStore.todos
    }
    
    func retrieveTodos() {
        presenter?.didRetrieveTodos(todos)
    }
    
    func saveTodo(_ todo: TodoItem) {
        todoStore.addTodo(todo)
        presenter?.didAddTodo(todo)
    }
    
    func deleteTodo(_ todo: TodoItem) {
        todoStore.removeTodo(todo)
        presenter?.didRemoveTodo(todo)
    }
    
}

Внедрение протокола TodoListRouter

TodoListRouter предоставляет статическую функцию, которую можно вызывать для создания экземпляра TodoListModule, создания экземпляра конкретной реализации компонента TodoListProtocols, назначения ссылки для каждого компонента, а затем возврата UIViewController для отображения.

Он также предоставляет PresentTodoDetailScreen, который будет вызываться TodoListPresenter, когда пользователь выбирает TodoItem из TableView. Этот метод создает экземпляр TodoDetailModule, передавая TodoItem из статического метода TodoDetailRouter, и переходит к TodoDetailViewController, проталкивая UIViewController через стек UINavigationController.

import UIKit

class TodoListRouter: TodoListRouterProtocol {
    
    static var storyboard: UIStoryboard {
        return UIStoryboard(name: "Main", bundle: Bundle.main)
    }
    
    static func createTodoListModule() -> UIViewController {
        let navController = storyboard.instantiateViewController(withIdentifier: "TodoListNavigation") as! UINavigationController
        guard let todoListViewController = navController.topViewController as? TodoListViewController else { fatalError("Invalid View Controller") }
        let presenter: TodoListPresenterProtocol & TodoListInteractorOutputProtocol = TodoListPresenter()
        let interactor: TodoListInteractorInputProtocol = TodoListInteractor()
        let router = TodoListRouter()
        
        todoListViewController.presenter = presenter
        presenter.view = todoListViewController
        presenter.interactor = interactor
        presenter.router = router
        interactor.presenter = presenter
        
        return navController
    }

    
    func presentToDoDetailScreen(from view: TodoListViewProtocol, for todo: TodoItem) {
        
        let todoDetailVC = TodoDetailRouter.createTodoDetailRouterModule(with: todo)
        
        guard let viewVC = view as? UIViewController else {
            fatalError("Invalid View Protocol type")
        }
        
        viewVC.navigationController?.pushViewController(todoDetailVC, animated: true)
    }
    
}

Сборка TodoDetailModule/Screen

Протоколы TodoDetailModule

Как и TodoListModule, протокол определяет границы того, как каждый компонент будет взаимодействовать в модуле TodoDetail.

import UIKit

protocol TodoDetailViewProtocol: class {
    
    var presenter: TodoDetailPresenterProtocol? { get set }
    
    // PRESENTER -> VIEW
    func showToDo(_ todo: TodoItem)
}

protocol TodoDetailPresenterProtocol: class {
    
    var view: TodoDetailViewProtocol? { get set }
    var interactor: TodoDetailInteractorInputProtocol? { get set }
    var router: TodoDetailRouterProtocol? { get set }
    
    // VIEW -> PRESENTER
    func viewDidLoad()
    func editTodo(title: String, content: String)
    func deleteTodo()
}

protocol TodoDetailInteractorInputProtocol: class {
    
    var presenter: TodoDetailInteractorOutputProtocol? { get set }
    var todoItem: TodoItem? { get set }
    
    // PRESENTER -> INTERACTOR
    func deleteTodo()
    func editTodo(title: String, content: String)
}

protocol TodoDetailInteractorOutputProtocol: class {
    
    // INTERACTOR -> PRESENTER
    func didDeleteTodo()
    func didEditTodo(_ todo: TodoItem) 
}

protocol TodoDetailRouterProtocol: class {
    
    static func createTodoDetailRouterModule(with todo: TodoItem) -> UIViewController
    
    // PRESENTER -> ROUTER
    func navigateBackToListViewController(from view: TodoDetailViewProtocol)
    
}

Реализовать TodoDetailViewProtocol

TodoDetailViewController — это подкласс UIViewController, реализующий TodoDetailViewProtocol. Когда view загружается, оно вызывает метод TodoDetailPresenter viewDidLoad для презентатора, чтобы попросить интерактора получить ToDoItem для отображения в пользовательском интерфейсе.

ShowTodoItem будет вызываться presenter, передающим TodoItem для view, чтобы отобразить заголовок и содержимое TodoItem с помощью UILabels. View также передает действие пользователя по редактированию и удалению при нажатии кнопки на метод deleteTodo презентатора. Для действия редактирования UIAlertController, содержащий TextField, заполненный текущим заголовком TodoItem и содержимым, будет отображаться для изменения пользователем. Когда они подтвердят, view ретранслирует пользовательский ввод из текстовых полей обратно в presenter, передавая новое значение заголовка и содержимого.

import UIKit

class TodoDetailViewController: UIViewController {
    
    @IBOutlet var titleLabel: UILabel!
    @IBOutlet var contentLabel: UILabel!
    
    var presenter: TodoDetailPresenterProtocol?
    
    override func viewDidLoad() {
        super.viewDidLoad()
        presenter?.viewDidLoad()
    }
    
    @IBAction func deleteTapped(_ sender: Any) {
        presenter?.deleteTodo()
    }
    
    @IBAction func editTapped(_ sender: Any) {
        let alertController = UIAlertController(title: "Edit Todo Item", message: "Enter title and content", preferredStyle: .alert)
        
        alertController.addTextField { $0.text = self.titleLabel.text }
        alertController.addTextField { $0.text = self.contentLabel.text }
        alertController.addAction(UIAlertAction(title: "Confirm", style: .default, handler: { [weak self](_) in
            let titleText = alertController.textFields![0].text ?? ""
            let contentText = alertController.textFields![1].text ?? ""
            guard !titleText.isEmpty else { return }
            self?.presenter?.editTodo(title: titleText, content: contentText)
        }))
        
        alertController.addAction(UIAlertAction(title: "Cancel", style: .cancel, handler: nil))
        present(alertController, animated: true, completion: nil)

    }
    
}

extension TodoDetailViewController: TodoDetailViewProtocol {
    
    func showToDo(_ todo: TodoItem) {
        titleLabel.text = todo.title
        contentLabel.text = todo.content
    }
    
}

Реализовать TodoDetailPresenterProtocol

TodoDetailPresenter реализует TodoDetailPresenterProtocol и TodoDetailInteractorOutputProtocol. Когда view загружено, презентатор запрашивает у интерактора TodoItem, а затем просит view отобразить TodoItem, вызывая showTodo.

Он также обрабатывает ретрансляцию действий пользователя из view для редактирования и удаления TodoItem в интерактор. Реализация TodoDetailInteractorOutputProtocol обрабатывает результат после того, как интерактор успешно редактирует элемент, чтобы обновить view на основе нового значения, для удаления он вызывает TodoDetailRouterProtocol для возврата к TodoListView, поскольку TodoItem был удален.

import UIKit

class TodoDetailPresenter: TodoDetailPresenterProtocol {

    weak var view: TodoDetailViewProtocol?
    var router: TodoDetailRouterProtocol?
    var interactor: TodoDetailInteractorInputProtocol?
    
    
    func viewDidLoad() {
        if let todoItem = interactor?.todoItem {
            view?.showToDo(todoItem)
        }
    }
    
    func editTodo(title: String, content: String) {
        interactor?.editTodo(title: title, content: content)
    }
    
    func deleteTodo() {
        interactor?.deleteTodo()
    }
    
}

extension TodoDetailPresenter: TodoDetailInteractorOutputProtocol {
    
    func didDeleteTodo() {
        if let view = view {
            router?.navigateBackToListViewController(from: view)
        }
    }
    
    func didEditTodo(_ todo: TodoItem) {
        view?.showToDo(todo)
    }
    
}

Реализовать TodoDetailInteractorProtocol

TodoDetailInteractor реализует TodoDetailInteractorInputProtocol, он также имеет ссылку на объект TodoStore и TodoItem. Докладчик может получить TodoItem из интерактора, обратившись к свойству. Он также предоставляет методы для удаления TodoItem и редактирования TodoItem. Действие удаления вызывает TodoStore removeTodo, передавая TodoItem, чтобы TodoItem можно было удалить из TodoStore.

import Foundation

class TodoDetailInteractor: TodoDetailInteractorInputProtocol {
    
    weak var presenter: TodoDetailInteractorOutputProtocol?
    var todoStore = TodoStore.shared
    var todoItem: TodoItem?
    
    func deleteTodo() {
        guard let todoItem = todoItem else { return }
        todoStore.removeTodo(todoItem)
        presenter?.didDeleteTodo()
    }
    
    func editTodo(title: String, content: String) {
        guard let todoItem = todoItem else { return }
        todoItem.title = title
        todoItem.content = content
        presenter?.didEditTodo(todoItem)
    }
    
}

Реализовать TodoDetailRouterProtocol

TodoDetailRouter реализует TodoDetailRouterProtocol. Он предоставляет статический метод для создания экземпляра TodoDetailModule, настройки всех компонентов и их связывания, а затем возвращает UIViewController для отображения. Он также предоставляет navigationBackToListViewController, который будет вызываться, когда пользователь удаляет TodoItem с экрана TodoDetail для перехода назад после удаления TodoItem.

import UIKit

class TodoDetailRouter: TodoDetailRouterProtocol {
    
    func navigateBackToListViewController(from view: TodoDetailViewProtocol) {
        guard let viewVC = view as? UIViewController else {
            fatalError("Invalid view protocol type")
        }
        viewVC.navigationController?.popViewController(animated: true)
    }
    
    static func createTodoDetailRouterModule(with todo: TodoItem) -> UIViewController {
        
        guard let todoDetailVC = storyboard.instantiateViewController(withIdentifier: "TodoDetailViewController") as? TodoDetailViewController else {
            fatalError("Invalid view controller type")
        }
        
        let presenter: TodoDetailPresenter & TodoDetailInteractorOutputProtocol = TodoDetailPresenter()
        todoDetailVC.presenter = presenter
        presenter.view = todoDetailVC
        let interactor: TodoDetailInteractorInputProtocol = TodoDetailInteractor()
        interactor.todoItem = todo
        interactor.presenter = presenter
        presenter.interactor = interactor
        let router: TodoDetailRouterProtocol = TodoDetailRouter()
        presenter.router = router
        
        return todoDetailVC
    }
    
    static var storyboard: UIStoryboard {
        return UIStoryboard(name: "Main", bundle: Bundle.main)
    }
    
}

Настройка и интеграция AppDelegate

Настройка AppDelegate довольно проста и непосредственна: внутри applicationDidFinishLaunchingWithOptions мы создаем экземпляр TodoListViewController, вызывая TodoListRouter.createTodoListModule, который настраивает компоненты TodoListModule. Затем мы создаем экземпляр UIWindow и назначаем ViewController в качестве rootViewController окна в качестве нашего начального экрана.

import UIKit

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {

    var window: UIWindow?

    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        
        let todoListView = TodoListRouter.createTodoListModule()
        window = UIWindow(frame: UIScreen.main.bounds)
        window?.rootViewController = todoListView
        window?.makeKeyAndVisible()
        
        return true
    }

}

Вывод

Создание приложения iOS с помощью VIPER обеспечивает парадигму чистой архитектуры, разделяя обязанности каждого уровня приложения на отдельный компонент, что делает наше приложение модульным между логикой представления и бизнес-логикой. Компонент модульный, поэтому мы можем легко добавлять дополнительные функции.

Одним из основных недостатков VIPER является то, что нам нужно написать слишком много стандартного кода для протоколов каждого компонента, что является излишним в небольшом приложении. Наконец, решение о том, использовать ли VIPER в нашем проекте или нет, будет зависеть от масштаба нашего проекта, от того, насколько наша команда знакома с его использованием, и от конкретных вариантов использования нашего приложения.