Применение MVP+TDD в разработке iOS приложений

— Слава TDD!
— Юнит-тестам слава!

В этой статье мы разберемся с принципами применения MVP+TDD в разработке iOS приложений. Разбираться будем на примере создания небольшой обучалки для пользователя, которая показывается при первом запуске.

Требования от бизнеса

Итак, ваш заказчик хочет, чтоб в его приложение добавили обучалку, которая покажется пользователю один раз при первом запуске. Обучалка состоит из нескольких изображений, которые должны быть показаны в определенной последовательности. Переключаться изображения должны по нажатию на кнопку «Продолжить». Также при показе последнего изображения — на кнопке нужно написать «Старт» (как бы намекая пользователю, что приложение будет сейчас запущено).

Шаг 1. Продумываем логику.

  1. Итак у нас есть определенная последовательность изображений. Это значит, что у нас должна быть очередь (queue) этих изображений. Из этой очереди мы будем извлекать по одному изображению и показывать на экране. И так до тех пор, пока изображения в очереди не закончатся.

  2. Когда в очереди останется последнее изображение — нужно поменять надпись на кнопке: вместо «Продолжить» — «Старт».

  3. Когда пользователь увидит последнее изображение и нажмет кнопку «Старт» нам нужно показать первый экран приложения и зафиксировать, что пользователь уже смотрел обучалку (не показывать ее при последующих запусках).

Шаг 2. MVP

  1. Для начала создадим наше приложение (новый проект в Xcode) и назовем его OnBoardingTest.

    Сразу включите в проект и юнит-тесты (TDD ведь).

  2. Добавим в наш проект модуль для нашей обучалки. Назовем его OnBoarding.

Освежить знания об MVP можно перечитав мой пост Через MVP к VIPER. Часть первая: MVP.

Обычно, модуль в MVP-архитектуре представляет из себя два класса (UIViewController и Presenter), которые общаются между собой с помощью протоколов. Но, если честно, у меня не было случая, когда я использовал для одного и того же вью разные презентеры. Поэтому в одном из протоколов (viewOutput/presenterInput) просто нет необходимости.

Наш модуль состоит из следующих файлов:


Для каждого экрана я использую отдельный сториборд, детальнее можно прочитать в статье-переводе Xcode: наверное, лучший способ работы со сторибордами

Шаг 3. V — означает View.

Поскольку мы не тестируем слой View — можем сразу заняться его реализацией. View — это наш сториборд и UIViewController.

UI на сториборде у нас очень простой: ImageView на весь экран и кнопка:

Во вью-контроллер добавим необходимые IBOutlet-ы и IBAction. Наш класс выглядит пока что так:

class OnBoardingViewController: ViewController {

    @IBOutlet weak var imageView: UIImageView!
    @IBOutlet weak var continueButton: UIButton!

    @IBAction func continueAction(_ sender: UIButton) {

    }

}

Также во вью-контроллер нужно добавить ссылку на презентер и сборку модуля. Добавляем свойство:

var presenter: OnBoardingPresenter!

Сборку будем выполнять в awakeFromNib:

override func awakeFromNib() {
    super.awakeFromNib()
    self.presenter = OnBoardingPresenter()
}

Теперь нам нужно добавить методы в наш протокол и реализовать их во вью-контроллере. Что должен уметь наш вью-контроллер: во-первых он должен уметь менять изображение в UIImageView, во-вторых: менять надпись на кнопке, в-третьих: запускать приложение (когда все изображения показаны). Добавим в протокол следующие методы:

func show(image: UIImage)
func updateButton(title: String)
func startApplication()

Для первого метода есть важный нюанс: мы пытаемся передавать параметром UIImage, что немного противоречит принципу разделения UI-слоя от слоя с логикой. Если так и оставить, то получится, что нам нужно в презентере создать объект типа UIImage и передать его в вызов метода show(image:), а презентер не должен работать с UIKit. Поэтому мы немного исправим параметр в первом методе:

func showImage(imageName: String)

Будем передавать идентификатор изображения, а вью-контроллер сам будет создавать UIImage с таким идентификатором.

Теперь реализуем этот протокол в нашем вью-контроллере:

// MARK: - OnBoardingViewProtocol

extension OnBoardingViewController: OnBoardingViewProtocol {

    func showImage(imageName: String) {
        self.imageView.image = UIImage(named: imageName)
    }

    func updateButton(title: String) {
        self.continueButton.setTitle(title, for: .normal)
    }

    func startApplication() {
        // some logic for application start
        print("Application is started")
    }

}

Как видите, реализация очень проста: создать UIImage и показать его в нашем self.imageView и поставить надпись на кнопку. Логику старта приложения мы реализовывать не будем, поскольку в рамках этой статьи — это не критично. Там может быть вызов какого-либо вью-контроллера (через self.present(...)), либо более сложная логика, которая должна быть вынесена в какой-нибудь роутер. Выведем в консоль сообщение, чтоб увидеть, что старт приложения вызывается.

Реализация нашей вью готова.

Шаг 4. M — для Model

Моделью в нашем случае будет:
1. Очередь из названий изображений.
2. Сервис, который зафиксирует, что обучалка показана полностью, и в следующий раз ее показывать не нужно.

Очередь из названий реализуется просто. Поскольку это названия (String) и они должны быть упорядочены, значит очередь будет представлять из себя массив строк: Array.

Добавим в наш модуль сервис OnBoardingImageManager. В нем у нас будет один метод getImageQueue() -> [String]:

class OnBoardingImageManager {

    func getImageQueue() -> [String] {
        // some logic may be here
        return ["OnBoarding1",
                "OnBoarding2",
                "OnBoarding3",
                "OnBoarding4",
                "OnBoarding5"]
    }

}

В нашем случае мы не будем углубляться в логику формирования очереди изображений и вернем массив захардкоженных строк. На самом же деле, здесь, как минимум, должна быть логика получения разных названий изображений для разных диагоналей экрана (не показывать же изображение для 4.0″ экрана на экране с диагональю 5.5″).

Сервис, который запомнит, что пользователь уже смотрел обучалку назовем OnBoardingLocalManager. В нем у нас также будет один метод setFlagOnBoardingCompleted():

class OnBoardingLocalManager {

    func setFlagOnBoardingCompleted() {
        // some logic for saving onBoardingCompleted flag
        // maybe use UserDefaults.standard
        print("OnBoarding completed successfully")
    }

}

Как видите его логику мы тоже реализовывать не будем. Тут могут быть использованы UserDefaults или CoreData (в особых случаях), либо еще какая-то логика (возможно есть требование передать на сервер инфу о том, что пользователь уже посмотрел обучалку). Поэтому мы просто выведем в консоль сообщение, чтоб увидеть, что этот метод вызывается.

Мы практически закончили с MVP. Осталось разобраться с презентером и перейдем к TDD.

Шаг 5. P — Presenter.

На текущий момент наш презентер гол как сокол:

class OnBoardingPresenter {

}

Давайте разберемся какие данные он может хранить и что он должен делать:
1. Он должен хранить очередь из названий изображений, причем эта очередь будет изменяться (каждый раз из нее будет извлечено следующее название, пока очередь не станет пустой).
2. Он должен выполнять извлечение следующего названия из очереди и передавать его в нашу вью.
3. Он должен определить, что очередь закончилась и сообщить вью об этом, а также зафиксировать, что пользователь посмотрел обучалку полностью.

Добавим в наш презентер очередь из названий картинок:

var imageQueue: [String]

Компилятор сразу же попросит добавить инициализатор, поскольку свойство imageQueue не опциональное и должно быть заполнено значением при инициализации. Добавим инициализатор:

init(imageQueue: [String] = OnBoardingImageManager().getImageQueue()) {
    self.imageQueue = imageQueue
}

Как видите в инициализатор мы передаем нашу очередь параметром. Причем у него есть значение по умолчанию. Почему нельзя сделать инициализатор без параметров и присваивать значение self.imageQueue внутри init-а? Потому что во время тестов мы будем подставлять свои очереди.

Теперь мы добавим в презентер метод showNextImage(...):

func showNextImage(view: OnBoardingViewProtocol,
                   localManager: OnBoardingLocalManager = OnBoardingLocalManager()) {


}

В нем пока нет реализации, но есть два параметра, давайте разберемся с ними.
view — это наша вью, которой презентер после выполнения логики отдаст команду (команды) сделать те или иные действия предусмотренные протоколом OnBoardingViewProtocol.
localManager — наш сервис, который запоминает, что пользователь посмотрел обучалку. Мы добавили этот параметр, для того, чтоб в тестах подставить вместо него МОК. И дали ему значение по умолчанию, чтоб не указывать каждый раз этот объект в вызовах метода из вью-контроллера.

Вернемся на минуту к нашей вью (OnBoardingViewController) и добавим в нее обращения к презентеру:

override func viewWillAppear(_ animated: Bool) {
    super.viewWillAppear(animated)
    self.presenter.showNextImage(view: self)
}

@IBAction func continueAction(_ sender: UIButton) {
    self.presenter.showNextImage(view: self)
}

Мы добавляем вызов во viewWillAppear (экран обучения должен быть показан с первым же изображением из очереди) и в обработчик нажатия на кнопку.

Мы вплотную подошли к TDD.

Шаг 6. МОКи

Для того, чтоб написать тесты для нашего презентера, нужно сделать МОКи объектов с которыми он взаимодействует. А именно: для view, которая имеет тип OnBoardingViewProtocol, и для localManager, тип которого — OnBoardingLocalManager. Для создания МОКов в Swift я пользуюсь библиотекой Cuckoo. Как с ней работать я писал в статье «Мочим» объекты с помощью Cuckoo. Добавляем библиотеку в проект через CocoaPods, делаем pod install в папке с проектом, и в скрипте для генерации МОКов указываем файлы OnBoardingViewProtocol ("${INPUT_DIR}/OnBoardingViewProtocol.swift") и OnBoardingLocalManager ("${INPUT_DIR}/OnBoardingLocalManager.swift").

Шаг 7. TDD. RED

Мы готовы к TDD. Теперь нам нужно написать юнит-тесты, которые опишут необходимую логику, но будут «фэйлиться».

В таргете OnBoardingTestTests удаляем лишний файл OnBoardingTestTests.swift (Xcode создал его по умолчанию) и создаем файл OnBoardingPresenterTest — наследник от XCTestCase:

import XCTest

class OnBoardingPresenterTests: XCTestCase {

}

Структура юнит-теста следующая:
instance — объект класса, который мы тестируем
— объявления МОКов, которые нужны для тестирования
— корректные/некорректные входящие данные и ожидаемые результаты

Объявляем сущности, которые нам понадобятся в тестах:

import XCTest
import Cuckoo
@testable import OnBoardingTest

class OnBoardingPresenterTests: XCTestCase {

    var instance: OnBoardingPresenter!

    var view: MockOnBoardingViewProtocol!
    var localManager: MockOnBoardingLocalManager!

    let correctNexImageName = "correctNextImageName"
    var fullImageQueue: [String]!
    let lastImageQueue: [String] = ["something"]
    let emptyImageQueue: [String] = []

    override func setUp() {
        super.setUp()

        self.fullImageQueue = [correctNexImageName, "something", "something else"]
    }

}

Пройдемся по ним по очереди:
instance у которого тип OnBoardingPresenter — наш презентер, который мы и будем тестировать.
view типа MockOnBoardingViewProtocol и localManager типа MockOnBoardingLocalManager — наши МОКи.
— разные варианты очередей: очередь из нескольких названий, очередь из одного названия и пустая очередь.

Добавим в метод setUp() инициализацию МОКов и stub-ы для них:

self.view = MockOnBoardingViewProtocol()
stub(self.view) {
    when($0.showImage(imageName: anyString())).thenDoNothing()
    when($0.updateButton(title: anyString())).thenDoNothing()
    when($0.startApplication()).thenDoNothing()
}

self.localManager = MockOnBoardingLocalManager()
stub(self.localManager) {
    when($0.setFlagOnBoardingCompleted()).thenDoNothing()
}

Мы готовы написать непосредственно тесты.

Первый тест будет тестировать, что изображение из очереди извлекается правильно:

func testNextImageExtractsCorrectly() {
    self.instance = OnBoardingPresenter(imageQueue: self.fullImageQueue)
    self.instance.showNextImage(view: self.view)
    verify(self.view).showImage(imageName: self.correctNexImageName)
}

В этом тесте мы инициализируем наш презентер с очередью fullImageQueue, вызываем метод презентера showNextImage(...) и проверяем, что у нашей view вызвался метод обновления изображения, причем с правильным именем изображения.

Второй тест будет проверять, что мы извлекли элемент из нашей очереди (количество элементов в очереди уменьшилось на единицу):

func testImageQueueReducesCorrectly() {
    self.instance = OnBoardingPresenter(imageQueue: self.fullImageQueue)
    self.instance.showNextImage(view: self.view)
    XCTAssertEqual(self.instance.imageQueue.count, self.fullImageQueue.count - 1, "image queue should be reduced by one")
}

В третьем тесте мы проверим, что, в случае, когда в очереди несколько названий картинок, устанавливается правильная надпись на кнопке:

func testButtonTitleUpdatesCorrectly() {
    self.instance = OnBoardingPresenter(imageQueue: self.fullImageQueue)
    self.instance.showNextImage(view: self.view)
    verify(self.view).updateButton(title: "Продолжить")
}

Четвертый тест будет тестировать случай, когда в очереди осталось только одно название изображения. В этом случае надпись на кнопке должна измениться на «Старт»:

func testPrepareForApplicationStartCorrectly() {
    self.instance = OnBoardingPresenter(imageQueue: self.lastImageQueue)
    self.instance.showNextImage(view: self.view)
    verify(self.view).updateButton(title: "Старт")
}

Конечно же, строки «Продолжить» и «Старт» нужно выносить в глобальные константы, но в нашем учебном примере мы на это не будем тратить время.

Пятый тест будет проверять, что приложение стартует, когда очередь из названий пустая:

func testApplicationStartsCorrectly() {
    self.instance = OnBoardingPresenter(imageQueue: self.emptyImageQueue)
    self.instance.showNextImage(view: self.view, localManager: self.localManager)
    verify(self.view).startApplication()
}

И шестой тест проверит, что наш localManager вызовет метод сохранения флага об успешном прохождении обучалки пользователем:

func testLocalManagerSetsOnBoardingFlagCorrectly() {
    self.instance = OnBoardingPresenter(imageQueue: self.emptyImageQueue)
    self.instance.showNextImage(view: self.view, localManager: self.localManager)
    verify(self.localManager).setFlagOnBoardingCompleted()
}

Запустим тесты и получим «успешный» результат для первого этапа TDD:

Все тесты красные. Переходим к следующему этапу.

Шаг 8. TDD. GREEN

Наконец-то мы можем заняться программированием логики нашего OnBoardingPresenter. Итак, первое, что мы сделаем — извлечение следующего элемента из очереди. Реализуем это просто беря из массива первый элемент и проверяя его на nil:

if let nextImageName = self.imageQueue.first {

} else {

}

Если мы получили следующий элемент из очереди — нужно сказать view, чтоб она его показала: view.showImage(imageName: nextImageName). Если же следующего элемента нет — нужно сказать view, что пора стартовать приложение: view.startApplication(). Сейчас метод showNextImage(...) выглядит так:

func showNextImage(view: OnBoardingViewProtocol,
                   localManager: OnBoardingLocalManager = OnBoardingLocalManager()) {

    if let nextImageName = self.imageQueue.first {
        view.showImage(imageName: nextImageName)
    } else {
        view.startApplication()
    }
}

Чтоб определить достаточно ли кода мы написали — запускаем юнит-тесты и смотрим успешно ли они выполняются:

Мы видим, что два из шести тестов уже проходят успешно, но кода написано недостаточно.
Продолжаем кодить: поскольку мы взяли название изображения из очереди — нужно его оттуда убрать, также нужно дать команду нашему localManager, что обучалка пользователем просмотрена:

func showNextImage(view: OnBoardingViewProtocol,
                   localManager: OnBoardingLocalManager = OnBoardingLocalManager()) {

    if let nextImageName = self.imageQueue.first {
        view.showImage(imageName: nextImageName)
        self.imageQueue = Array(self.imageQueue.dropFirst())
    } else {
        view.startApplication()
        localManager.setFlagOnBoardingCompleted()
    }
}

Еще раз запускаем тесты и смотрим результат:

Успешно пройдены четыре из шести. Осталось только правильно присвоить надпись нашей кнопке:

if self.imageQueue.first != nil {
    view.updateButton(title: "Продолжить")
} else {
    view.updateButton(title: "Старт")
}

Еще раз напоминаю на всякий случай, что строки «Продолжить» и «Старт» должны быть вынесены в глобальные константы.

Еще раз запустим тесты и вуаля:

Все наши тесты проходят успешно. Мы написали достаточно кода для реализации необходимого нам функционала. Полностью наш презентер выглядит так:

class OnBoardingPresenter {

    var imageQueue: [String]

    init(imageQueue: [String] = OnBoardingImageManager().getImageQueue()) {
        self.imageQueue = imageQueue
    }

    func showNextImage(view: OnBoardingViewProtocol,
                       localManager: OnBoardingLocalManager = OnBoardingLocalManager()) {

        if let nextImageName = self.imageQueue.first {
            view.showImage(imageName: nextImageName)
            self.imageQueue = Array(self.imageQueue.dropFirst())
            if self.imageQueue.first != nil {
                view.updateButton(title: "Продолжить")
            } else {
                view.updateButton(title: "Старт")
            }
        } else {
            view.startApplication()
            localManager.setFlagOnBoardingCompleted()
        }
    }

}

Шаг 9. TDD. REFACTOR

Успешного выполнения тестов недостаточно, необходимо посмотреть на свой код и привести его в порядок: отрефакторить. В данном случае мне не нравится вот этот кусок:

if self.imageQueue.first != nil {
    view.updateButton(title: "Продолжить")
} else {
    view.updateButton(title: "Старт")
}

Тут повторяется вызов view.updateButton(...), отличие лишь в значении параметра, который в этот метод передается. Можно переписать его так:

let buttonTitle: String
if self.imageQueue.first != nil {
    buttonTitle = "Продолжить"
} else {
    buttonTitle = "Старт"
}
view.updateButton(title: buttonTitle)

Запустим тесты и убедимся, что не нарушили логику: тесты все еще выполняются корректно.

Теперь нужно заменить проверку self.imageQueue.first != nil на self.imageQueue.count != 0 (не обязательно пытаться взять первый элемент в массиве, достаточно просто проверить количество элементов в массиве). Запускаем тесты и видим, что все ок.

Но и на этом не останавливаемся, проверка self.imageQueue.count != 0 тоже не совсем правильная, ведь у массива есть свойство isEmpty. Поэтому меняем self.imageQueue.count != 0 на self.imageQueue.isEmpty и воспользуемся тернарным оператором (? :): let buttonTitle = self.imageQueue.isEmpty ? "Продолжить" : "Старт".

Запускаем юнит-тесты и видим такую картину:

Юнит-тесты помогают нам понять, что мы допустили ошибку и, даже, где именно мы ее допустили: мы перепутали местами строки «Продолжить» и «Старт». Внесем правки: let buttonTitle = self.imageQueue.isEmpty ? "Старт" : "Продолжить", и запустим тесты еще раз:

Вот теперь все в порядке, и мы закончили третий этап TDD.

Шаг 10. Финал

Согласно методологии TDD мы реализовали необходимый и достаточный функционал для нашей обучалки. Давайте посмотрим как она выглядит на экране.

Для этого нам нужно добавить необходимые изображения в Assets (в конце статьи будет ссылка на репозиторий с этим проектом и там будут все изображения), и установить наш OnBoardingViewController.storyboard, как стартовый для приложения:

Запускаем наше приложение и видим следующее:

Проходим обучалку до конца и видим надпись «Старт» на кнопке, когда показано последнее изображение:

После нажатия на кнопку «Старт» в консоли видим сообщения «Application is started» и «OnBoarding completed successfully»:

Это означает, что наша view получила команду запустить приложение, а localManager зафиксировал, что пользователь успешно просмотрел обучалку.

Итог

Весь проект, который мы с вами сделали можете скачать тут: Swift, Objective-C.

Спасибо за внимание. Всем добра.

Реклама

Паттерны проектирования, взгляд iOS разработчика. Часть 2. Наблюдатель

Содержание:

Часть 0. Синглтон-Одиночка
Часть 1. Стратегия
Часть 2. Наблюдатель

Сегодня мы разберемся с «начинкой» паттерна «Наблюдатель». Сразу оговорюсь, что в мире iOS у вас не будет острой необходимости реализовывать этот паттерн, поскольку в SDK уже есть NotificationCenter. Но в образовательных целях мы полностью разберем анатомию и применение этого паттерна. К тому же, самостоятельная реализация может обладать большей гибкостью и, в некоторых случаях, быть более полезной.

«Кажется дождь собирается» (с)

Авторы книги «Паттерны проектирования» (Эрик и Элизабет Фримен), в качестве примера, предлагают применять паттерн «Наблюдатель» к разработке приложения Weather Station. Представьте, что у нас есть: метеостанция, и объект WeatherData, который обрабатывает данные от ее датчиков и передает их нам. Приложение же состоит из трех экранов: экрана текущего состояния погоды, экрана статистики и экрана прогноза.

Мы знаем, что WeatherData предоставляет нам такой интерфейс:

// Objective-C
- (double)getTemperature;
- (double)getHumidity;
- (double)getPressure;
- (void)measurementsChanged;
// Swift
func getTemperature() -> Double
func getHumidity() -> Double
func getPressure() -> Double
func measurementsChanged()

Также разработчики WeatherData сообщили, что при каждом обновлении погодных датчиков будет вызван метод measurementsChanged.

Конечно же, самое простое решение — написать код непосредственно в этом методе:

// Objective-C
- (void)measurementsChanged {
    double temp = [self getTemperature];
    double humidity = [self getHumidity];
    double pressure = [self getPressure];

    [currentConditionsDisplay updateWithTemp:temp humidity:humidity andPressure:pressure];
    [statisticsDisplay updateWithTemp:temp humidity:humidity andPressure:pressure];
    [forecastDisplay updateWithTemp:temp humidity:humidity andPressure:pressure];
}
// Swift
func measurementsChanged() {
    let temp = self.getTemperature()
    let humidity = self.getHumidity()
    let pressure = self.getPressure()

    currentConditionsDisplay.update(with: temp, humidity: humidity, and: pressure)
    statisticsDisplay.update(with: temp, humidity: humidity, and: pressure)
    forecastDisplay.update(with: temp, humidity: humidity, and: pressure)
}

Такой подход конечно же плох, потому что:
— программируем на уровне конкретных реализаций;
— сложная расширяемость в будущем;
— нельзя в рантайме добавлять/убирать экраны, на которых будет показана информация;
— … (свой вариант);

Поэтому паттерн «Наблюдатель» будет в этой ситуации очень кстати. Поговорим немного о характеристиках этого паттерна.

«Наблюдатель». Что под капотом?

Основные характеристики этого паттерна — наличие СУБЪЕКТА и, собственно, НАБЛЮДАТЕЛЕЙ. Связь, как вы уже догадались, один ко многим, и при изменении состояния СУБЪЕКТА происходит оповещение его НАБЛЮДАТЕЛЕЙ. На первый взгляд все просто.

Первое что нам понадобится — интерфейсы (протоколы) для наблюдателей и субъекта:

// Objective-C
@protocol Observer <NSObject>

- (void)updateWithTemperature:(double)temperature
                     humidity:(double)humidity
                  andPressure:(double)pressure;

@end

@protocol Subject <NSObject>

- (void)registerObserver:(id<Observer>)observer;
- (void)removeObserver:(id<Observer>)observer;
- (void)notifyObservers;

@end
// Swift
protocol Observer: class {
    func update(with temperature: Double, humidity: Double, and pressure: Double)
}

protocol Subject: class {
    func register(observer: Observer)
    func remove(observer: Observer)
    func notifyObservers()
}

Теперь нужно привести в порядок WeatherData (подписать на соотв. протокол и не только):

// Objective-C

// файл заголовка WeatherData.h
@interface WeatherData : NSObject <Subject>

- (void)measurementsChanged;
- (void)setMeasurementWithTemperature:(double)temperature
                             humidity:(double)humidity
                          andPressure:(double)pressure; // test method

@end

// файл реализации WeatherData.m
@interface WeatherData()

@property (strong, nonatomic) NSMutableArray<Observer> *observers;
@property (assign, nonatomic) double temperature;
@property (assign, nonatomic) double humidity;
@property (assign, nonatomic) double pressure;

@end

@implementation WeatherData

- (instancetype)init
{
    self = [super init];
    if (self) {
        self.observers = [[NSMutableArray<Observer> alloc] init];
    }
    return self;
}

- (void)registerObserver:(id<Observer>)observer {
    [self.observers addObject:observer];
}

- (void)removeObserver:(id<Observer>)observer {
    [self.observers removeObject:observer];
}

- (void)notifyObservers {
    for (id<Observer> observer in self.observers) {
        [observer updateWithTemperature:self.temperature
                               humidity:self.humidity
                            andPressure:self.pressure];
    }
}

- (void)measurementsChanged {
    [self notifyObservers];
}

- (void)setMeasurementWithTemperature:(double)temperature
                             humidity:(double)humidity
                          andPressure:(double)pressure {

    self.temperature = temperature;
    self.humidity = humidity;
    self.pressure = pressure;
    [self measurementsChanged];
}

@end
// Swift
class WeatherData: Subject {

    private var observers: [Observer]
    private var temperature: Double!
    private var humidity: Double!
    private var pressure: Double!

    init() {
        self.observers = [Observer]()
    }

    func register(observer: Observer) {
        self.observers.append(observer)
    }

    func remove(observer: Observer) {
        self.observers = self.observers.filter { $0 !== observer }
    }

    func notifyObservers() {
        for observer in self.observers {
            observer.update(with: self.temperature, humidity: self.humidity, and: self.pressure)
        }
    }

    func measurementsChanged() {
        self.notifyObservers()
    }

    func setMeasurement(with temperature: Double,
                        humidity: Double,
                        and pressure: Double) { // test method

        self.temperature = temperature
        self.humidity = humidity
        self.pressure = pressure
        self.measurementsChanged()
    }

}

Мы добавили тестовый метод setMeasurement для имитации изменения состояний датчиков.

Поскольку методы register и remove у нас редко будут меняться от субъекта к субъекту, было бы хорошо иметь их реализацию по умолчанию. В Objective-C для этого нам понадобится дополнительный класс. Но для начала переименуем наш протокол и уберем из него эти методы:

// Objective-C
@protocol SubjectProtocol <NSObject>

- (void)notifyObservers;

@end

Теперь добавим класс Subject:

// Objective-C

// файл заголовка Subject.h
@interface Subject : NSObject

@property (strong, nonatomic) NSMutableArray<Observer> *observers;

- (void)registerObserver:(id<Observer>)observer;
- (void)removeObserver:(id<Observer>)observer;

@end

// файл реализации Subject.m
@implementation Subject

- (void)registerObserver:(id<Observer>)observer {
    [self.observers addObject:observer];
}

- (void)removeObserver:(id<Observer>)observer {
    [self.observers removeObject:observer];
}

@end

Как видите, в этом классе два метода и массив наших наблюдателей. Теперь в классе WeatherData убираем этот массив из свойств и унаследуемся от Subject, а не от NSObject:

// Objective-C
@interface WeatherData : Subject <SubjectProtocol>

В свифте, благодаря расширениям протоколов, дополнительный класс не понадобится.
Мы просто включим в протокол Subject свойство observers:

// Swift
protocol Subject: class {
    var observers: [Observer] { get set }

    func register(observer: Observer)
    func remove(observer: Observer)
    func notifyObservers()
}

А в расширении протокола напишем реализацию методов register и remove по умолчанию:

// Swift
extension Subject {

    func register(observer: Observer) {
        self.observers.append(observer)
    }

    func remove(observer: Observer) {
        self.observers = self.observers.filter {$0 !== observer }
    }

}

Принимаем сигналы

Теперь нам нужно реализовать экраны нашего приложения. Мы реализуем только один из них: CurrentConditionsDisplay. Реализация остальных аналогична.

Итак, создаем класс CurrentConditionsDisplay, добавляем в него два свойства и метод display (этот экран должен показывать текущее состояние погоды, как мы помним):

// Objective-C
@interface CurrentConditionsDisplay()

@property (assign, nonatomic) double temperature;
@property (assign, nonatomic) double humidity;

@end

@implementation CurrentConditionsDisplay

- (void)display {
    NSLog(@"Current conditions: %f degrees and %f humidity", self.temperature, self.humidity);
}

@end
// Swift
private var temperature: Double!
private var humidity: Double!

func display() {
    print("Current conditions: \(self.temperature) degrees and \(self.humidity) humidity")
}

Теперь нам нужно «подписать» этот класс на протокол Observer и реализовать необходимый метод:

// Objective-C

// в файле заголовка CurrentConditionsDisplay.h
@interface CurrentConditionsDisplay : NSObject <Observer>

// в файле реализации CurrentConditionsDisplay.m
- (void)updateWithTemperature:(double)temperature
                     humidity:(double)humidity
                  andPressure:(double)pressure {

    self.temperature = temperature;
    self.humidity = humidity;
    [self display];
}
// Swift
class CurrentConditionsDisplay: Observer {

    func update(with temperature: Double, humidity: Double, and pressure: Double) {
        self.temperature = temperature
        self.humidity = humidity
        self.display()
    }

Почти готово. Осталось зарегистрировать нашего наблюдателя у субъекта (также не забывайте удалять регистрацию при деинициализации).

Для этого нам понадобится еще одно свойство:

// Objective-C
@property (weak, nonatomic) Subject<SubjectProtocol> *weatherData;
// Swift
private weak var weatherData: Subject?

И инициализатор с деинициализатором:

// Objective-C
- (instancetype)initWithSubject:(Subject<SubjectProtocol> *)subject {
    self = [super init];
    if (self) {
        self.weatherData = subject;
        [self.weatherData registerObserver:self];
    }
    return self;
}

- (void)dealloc
{
    [self.weatherData removeObserver:self];
}
// Swift
init(with subject: Subject) {
    self.weatherData = subject
    self.weatherData?.register(observer: self)
}

deinit {
    self.weatherData?.remove(observer: self)
}

Заключение

Мы написали довольно простую реализацию паттерна «Наблюдатель». Наш вариант, конечно же не без изъянов. Например, если мы добавим четвертый датчик, то нужно будет переписывать интерфейс наблюдателей и реализации этого интерфейса (чтоб доставлять до наблюдателей четвертый параметр), а это не есть хорошо. В NotificationCenter, о котором я упоминал в самом начале статьи, такой проблемы не существует. Дело в том, что там передача данных происходит одним-единым параметром-словарем.

Спасибо за внимание, учитесь и учите других.
Ведь пока мы учимся — мы остаемся молодыми. 🙂

«Мочим» объекты с помощью Cuckoo

Пост написан по мотивам статьи Mocking in Swift with Cuckoo by Godfrey Nolan

По долгу своей «службы» мобильным разработчиком, предстала передо мной задача: разобраться с созданием и использованием Моков для юнит-тестирования. Моим коллегой была рекомендована библиотека Cuckoo. Стал я с ней разбираться и вот что из этого вышло.

Документация

Прочитав документацию на гитхабе мне, к сожалению, не удалось «завести» Cuckoo в моем проекте. Через CocoaPods этот фреймворк был установлен, но вот с Run-скриптом возникли проблемы: предложенный пример не создавал файл GeneratedMocks.swift в папке с тестами, и я бы и не разобрался почему, если бы не нашел через гугл статью, которую упомянул в начале поста.

Итак, пройдем все этапы вместе и разберемся с некоторыми нюансами.

Тестовый проект

Естественно, нам нужен какой-нибудь проект в который мы подключим Cuckoo и напишем несколько тестов. Откройте Xcode, и создайте новый Single View Application: язык — Swift, обязательно поставьте галочку Include Unit Tests, имя проекта — UrlWithCuckoo.

Добавьте в проект новый Swift-файл и назовите его UrlSession.swift. Вот полный код:

import Foundation

class UrlSession {
    var url:URL?
    var session:URLSession?
    var apiUrl:String?

    func getSourceUrl(apiUrl:String) -> URL {
        url = URL(string:apiUrl)
        return url!
    }

    func callApi(url:URL) -> String {
        session = URLSession()
        var outputdata:String = ""
        let task = session?.dataTask(with: url as URL) { (data, _, _) -> Void in
            if let data = data {
                outputdata = String(data: data, encoding: String.Encoding.utf8)!
                print(outputdata)
            }
        }
        task?.resume()
        return outputdata
    }
}

Как видите, это простой класс с тремя свойствами и двумя методами. Именно для этого класса мы и будем создавать Мок.

Подключаем Cuckoo

Я использую в работе CocoaPods, поэтому для подключения Cuckoo добавлю в каталог с проектом Podfile такого вида:

platform :ios, '9.0'
use_frameworks!

target 'UrlWithCuckooTests' do
    pod 'Cuckoo'
end

Естественно нужно запустить pod install в терминале из каталога с проектом, и после завершения установки открыть в Xcode UrlWithCuckoo.xcworkspace.

Следующим шагом добавляем Run-скрипт в Build Phases нашего «таргета» тестирования (нужно нажать «+» и выбрать «New Run Script Phase»):

Вот полный текст скрипта:

# Define output file; change "${PROJECT_NAME}Tests" to your test's root source folder, if it's not the default name
OUTPUT_FILE="./${PROJECT_NAME}Tests/GeneratedMocks.swift"
echo "Generated Mocks File = ${OUTPUT_FILE}"

# Define input directory; change "${PROJECT_NAME}" to your project's root source folder, if it's not the default name
INPUT_DIR="./${PROJECT_NAME}"
echo "Mocks Input Directory = ${INPUT_DIR}"

# Generate mock files; include as many input files as you'd like to create mocks for
${PODS_ROOT}/Cuckoo/run generate --testable "${PROJECT_NAME}" \
--output "${OUTPUT_FILE}" \
"${INPUT_DIR}/UrlSession.swift"

Как видите, в комментариях в скрипте написано о необходимости заменить ${PROJECT_NAME} и ${PROJECT_NAME}Tests, но в нашем примере в этом нет необходимости.

Генерируем Мок(и)

Дальше нам нужно, чтоб этот скрипт сработал и создал в каталоге с тестами файл GeneratedMocks.swift, и просто сбилдить проект (Cmd+B) для этого недостаточно. Нужно сделать Build For -> Testing (Shift+Cmd+U):

Проверьте что в каталоге UrlWithCuckooTests появился файл GeneratedMocks.swift. Его (файл) также нужно добавить в сам проект: просто перетащите его из Finder в Xcode в UrlWithCuckooTests:

Наши Моки готовы, поговорим о некоторых нюансах.

1. Сложные файловые структуры

Если у вас в проекте присутствует нормальная файловая структура и файлы разложены по подпапкам, а не просто находятся в корневом каталоге, то в скрипт нужно внести некоторые корректировки.

Допустим вы используете в своем проекте MVP и вам нужен Мок для вью-контроллера модуля MainModule (он у вас в проекте, конечно же, лежит по адресу /Modules/MainModule/MainModuleViewController.swift). В этом случае вам нужно поменять последнюю строку в скрипте из нашего примера "${INPUT_DIR}/UrlSession.swift" на "${INPUT_DIR}/Modules/MainModule/MainModuleViewController.swift".

Также если вы хотите, чтоб файл GeneratedMocks.swift попадал не просто в корневой каталог тестов, а, например, в подпапку Modules, то вам нужно подкорректировать в скрипте вот эту строку: OUTPUT_FILE="./${PROJECT_NAME}Tests/GeneratedMocks.swift".

2. Нужны Моки нескольких классов

Очень вероятно (ожидаемая вероятность — 99.9%), что вам понадобятся Моки нескольких классов. Их можно сделать просто перечислив в конце скрипта файлы из которых нужно сделать Моки, разделив их обратными слэшами:

"${INPUT_DIR}/UrlSession.swift" \
"${INPUT_DIR}/Modules/MainModule/MainModuleViewController.swift" \
"${INPUT_DIR}/MyAwesomeObject.swift"

3. Аннотации типов

В классах к которым вы создаете Моки у всех свойств должны быть аннотации типов. Если у вас есть что-то типа такого:

var someBoolVariable = false

То при генерации Мока вы получите ошибку:

И в файле GeneratedMocks.swift будет фигурировать __UnknownType:

К сожалению Cuckoo не умеет определять тип по значению по умолчанию, и в таком случае необходимо явно указывать тип свойства:

var someBoolVariable: Bool = false

Пишем тесты

Теперь напишем несколько простых тестов используя наш Мок. Откроем файл UrlWithCuckooTests.swift и удалим из него два метода, которые создаются по умолчанию: func testExample() и func testPerformanceExample(). Они нам не понадобятся. И, конечно, не забудьте:

import Cuckoo

1. Свойства

Сначала напишем тесты для свойств. Создаем новый метод:

func testVariables() {

}

Инициализируем в нем наш Мок и пару дополнительных констант:

let mock = MockUrlSession()
let urlStr  = "http://habrahabr.ru"
let url  = URL(string:urlStr)!

Теперь нам нужно написать stub-ы для свойств:

// Arrange
stub(mock) { (mock) in
    when(mock.url).get.thenReturn(url)
}

stub(mock) { (mock) in
    when(mock.session).get.thenReturn(URLSession())
}

stub(mock) { (mock) in
    when(mock.apiUrl).get.thenReturn(urlStr)
}

Stub — это что-то типа подмены возвращаемого результата. Грубо говоря, мы описываем что вернет свойство нашего Мока, когда мы к нему обратимся. Как видите, мы используем thenReturn, но можем использовать и then. Это даст возможность не только вернуть значение, но и выполнить дополнительные действия. Например, наш первый stub можно описать и вот так:

// Arrange
stub(mock) { (mock) in
    when(mock.url).get.then { (_) -> URL? in

        // some actions here

        return url
    }
}

И, собственно, проверки (на значения и на nil):

// Act and Assert
XCTAssertEqual(mock.url?.absoluteString, urlStr)
XCTAssertNotNil(mock.session)
XCTAssertEqual(mock.apiUrl, urlStr)

XCTAssertNotNil(verify(mock).url)
XCTAssertNotNil(verify(mock).session)
XCTAssertNotNil(verify(mock).apiUrl)

2. Методы

Теперь протестируем вызовы методов нашего Мока. Создадим два тестовых метода:

func testGetSourceUrl() {

}

func testCallApi() {

}

В обоих методах также инициализируем наш Мок и вспомогательные константы:

let mock = MockUrlSession()
let urlStr  = "http://habrahabr.ru"
let url  = URL(string:urlStr)!

Также в методе testCallApi() добавим счетчик вызовов:

var callApiCount = 0

Дальше в обоих методах напишем stub-ы.

testGetSourceUrl():

// Arrange
stub(mock) { (mock) in
    mock.getSourceUrl(apiUrl: urlStr).thenReturn(url)
}

testCallApi():

// Arrange
stub(mock) { mock in
    mock.callApi(url: equal(to: url, equalWhen: { $0 == $1 })).then { (_) -> String in
        callApiCount += 1
        return "{'firstName': 'John','lastName': 'Smith'}"
    }
}

Проверяем первый метод:

// Act and Assert
XCTAssertEqual(mock.getSourceUrl(apiUrl: urlStr), url)
XCTAssertNotEqual(mock.getSourceUrl(apiUrl: urlStr), URL(string:"http://google.com"))
verify(mock, times(2)).getSourceUrl(apiUrl: urlStr)

(в последней строке мы проверяем, что метод вызывался два раза)

И второй:

// Act and Assert
XCTAssertEqual(mock.callApi(url: url),"{'firstName': 'John','lastName': 'Smith'}")
XCTAssertNotEqual(mock.callApi(url: url), "Something else")
verify(mock, times(2)).callApi(url: equal(to: url, equalWhen: { $0 == $1 }))
XCTAssertEqual(callApiCount, 2)

(тут мы тоже проверяем количество вызовов, причем двумя способами: с помощью verify и счетчика вызовов callApiCount, который мы объявляли ранее)

Запускаем тесты

После запуска проекта на тестирование (Cmd+U) мы увидим вот такую картину:

Все работает, отлично. 🙂

И напоследок

Ссылка на то что у нас в итоге получилось: https://github.com/ssuhanov/UrlWithCuckoo

Спасибо за внимание.

Паттерны проектирования, взгляд iOS разработчика. Часть 1. Стратегия

Содержание:

Часть 0. Синглтон-Одиночка
Часть 1. Стратегия
Часть 2. Наблюдатель

Напомню, что в этой серии статей, я разбираю книгу «Паттерны проектирования» Эрика и Элизабет Фримен. И сегодня мы изучим паттерн «Стратегия». Поехали.

Откуда растут ноги (и крылья)

Авторы книги рассказывают нам историю о создании приложения SimUDuck. Начнем с реализации начального состояния приложения: у нас есть абстрактный класс Duck и два его наследника: MallardDuck и RedheadDuck. Тут же мы сталкиваемся с первой сложностью: в Objective-C и Swift нет абстрактных классов.

Выходим из ситуации теми инструментами, что есть: для Objective-C объявляем обычный класс Duck (в нем будут реализации по умолчанию) и к нему добавляем протокол AbstractDuck (в нем будут абстрактные методы, которые нужно реализовать в наследниках). Выглядит это так:

// Objective-C
@protocol AbstractDuck <NSObject>

- (void)display;

@end

@interface Duck : NSObject

- (void)quack;
- (void)swim;

@end

Соответственно наследники будут такими:

// Objective-C
@interface MallardDuck : Duck <AbstractDuck>

@end

@implementation MallardDuck

- (void)display {

}

@end

@interface RedheadDuck : Duck <AbstractDuck>

@end

@implementation RedheadDuck

- (void)display {

}

@end

В Swift это сделать немного проще: достаточно протокола и его расширения (в расширении можно некоторые методы протокола реализовать по умолчанию):

// Swift
protocol Duck {
    func quack()
    func swim()
    func display()
}

extension Duck {

    func quack() {

    }

    func swim() {

    }

}

И наследники:

// Swift
class MallardDuck: Duck {

    func display() {

    }

}

class RedheadDuck: Duck {

    func display() {

    }

}

Приложение развивается и у уток появляется возможность летать

Для этого соответствующий метод появляется в родительском классе Duck. И вскоре после этого выясняется, что есть еще один наследник — RubberDuck. Резиновые утки ведь не летают, а поскольку метод добавлен в родительский класс, то он будет доступен и для резиновых уток. В общем: ситуация оказалась не из простых. При дальнейшем расширении приложения будут возникать сложности с поддержкой функций полета (и не только с ней, с функцией кряканья та же история) и с другими видами уток (деревянных, например).

Сначала авторы книги предлагают решать проблему вынесением функций полета и кряканья в отдельные интерфейсы (для Objective-c и Swift — протоколы) Flyable и Quackable. Но этот вариант оказывается совсем не так хорош, каким кажется на первый взгляд. Малейшее изменение функции полета, которое должно быть применено ко всем летающим уткам влечет за собой внесение одного и того же кода во многих местах программы. Так что такое решение определенно не подходит.

(говоря о негодности этого варианта, авторы ссылаются на то, что в интерфейсах (для нас протоколах) нет реализаций по умолчанию, но это справедливо лишь для Objective-C, а вот в Swift реализации по умолчанию для полета и кряканья можно было бы написать в расширениях этих протоколов и переопределять эти функции только там где необходимо, а не везде)

Ну и к тому же, одна из главных целей паттерна — подменяемая реализация поведений во время выполнения, поэтому авторы предлагают выносить реализации поведений из класса Duck.

Для этого создадим протоколы FlyBehavior и QuackBehavior:

// Objective-C
@protocol FlyBehavior <NSObject>

- (void)fly;

@end

@protocol QuackBehavior <NSObject>

- (void)quack;

@end
// Swift
protocol FlyBehavior {
    func fly()
}

protocol QuackBehavior {
    func quack()
}

И конкретные классы реализующие эти протоколы: FlyWithWings и FlyNoWay для FlyBehavior, а также Quack, Squeak и MuteQuack для QuackBehavior (приведу пример для FlyWithWings, остальные реализуются очень схожим образом) :

// Objective-C
@interface FlyWithWings : NSObject <FlyBehavior>

@end

@implementation FlyWithWings

- (void)fly {

    // fly implementation

}

@end
// Swift
class FlyWithWings: FlyBehavior {

    func fly() {

        // fly implementation

    }

}

Делегирование наше все

Теперь мы, по сути, делегируем наше поведение любому другому классу, который реализует соответствующий интерфейс (протокол). Как сказать нашей утке каким должно быть ее поведение в полете и при кряканьи? Очень просто, добавляем в наш класс (в Swift — протокол) Duck два свойства:

// Objective-C
@property (strong, nonatomic) id<FlyBehavior> flyBehavior;
@property (strong, nonatomic) id<QuackBehavior> quackBehavior;
// Swift
var flyBehavior: FlyBehavior { get set }
var quackBehavior: QuackBehavior { get set }

Как видите у них не определен конкретный тип, определено лишь, что это класс подписанный на соответствующий протокол.

Методы fly и quack нашего родительского класса (или протокола) Duck заменим аналогичными:

// Objective-C
- (void)performFly {
    [self.flyBehavior fly];
}

- (void)performQuack {
    [self.quackBehavior quack];
}
// Swift
func performFly() {
    flyBehavior.fly()
}

func performQuack() {
    quackBehavior.quack()
}

Теперь наша утка просто делегирует свое поведение соответствующему поведенческому объекту. Как мы устанавливаем поведение каждой утке? Например при инициализации (пример для MallardDuck):

// Objective-C
- (instancetype)init
{
    self = [super init];
    if (self) {
        self.flyBehavior = [[FlyWithWings alloc] init];
        self.quackBehavior = [[Quack alloc] init];
    }
    return self;
}
// Swift
init() {
    self.flyBehavior = FlyWithWings()
    self.quackBehavior = Quack()
}

Наш паттерн готов 🙂

Заключение

В iOS разработке паттерн «Стратегия» вы можете встретить, например, в архитектуре MVP: в ней презентер является не чем иным как поведенческим объектом для вью-контроллера (вью-контроллер, как вы помните, только сообщает презентеру о действиях пользователя, а вот логику обработки данных определяет презентер), и наоборот: вью-контроллер — поведенческий объект для презентера (презентер лишь говорит «показать пользователю данные», но как именно они будут показаны — решит вью-контроллер). Также этот паттерн вы встретите и в VIPER, если, конечно, надумаете его использовать в вашем приложении. 🙂

Паттерны проектирования, взгляд iOS разработчика. Часть 0. Синглтон-Одиночка

Я почув і забув.
Я записав і запам’ятав.
Я зробив і зрозумів.
Я навчив іншого, тепер я майстер.
(В. В. Бублик)

Небольшое вступление.

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

И именно эти слова и сподвигли меня на написание данной серии постов. Дело в том, что я — начинающий iOS разработчик, и я очень хочу разобраться в паттернах проектирования. И я не придумал лучшего способа, чем взять книгу «Паттерны проектирования» Эрика и Элизабет Фримен, и написать примеры каждого паттерна на Objective-C и Swift. Таким образом я смогу лучше понять суть каждого паттерна, а также особенности обоих языков.

Содержание:

Часть 0. Синглтон-Одиночка
Часть 1. Стратегия
Часть 2. Наблюдатель

Итак, начнем с самого простого на мой взгляд паттерна.

Одиночка, он же — синглтон.

Основная задача синглтона — предоставить пользователю один и только один объект определенного класса на весь жизненный цикл приложения. В iOS-разработке, как по мне — самый лучший пример необходимости такого объекта — класс UIApplication. Вполне логично, что в течение жизни нашего приложения у нас должен быть один-единственный объект класса UIApplication.

Итак, разберемся что такое синглтон в Objective-C и Swift на примерах из книги.

Давайте сначала узнаем как вообще создать объект какого-нибудь класса. Очень просто:

// Objective-C
[[MyClass alloc] init]
// Swift
MyClass()

И тут авторы подводят нас к мысли, что приведенным выше способом можно создать сколько угодно объектов этого класса. Таким образом, первое что нужно сделать на пути к синглтону — запретить создание объектов нашего класса извне. В этом нам поможет приватный инициализатор.

И если в swift это реализуется тривиально:

// Swift
class MyClass {

    private init() {}

}

То в objective-c все не так просто на первый взгляд. Дело в том, что все классы obj-c имеют одного общего предка: NSObject, в котором есть общедоступный инициализатор. Поэтому в файле заголовка нашего класса нужно указать на недоступность этого метода для нашего класса:

// Objective-C
@interface MyClass : NSObject

- (instancetype)init UNAVAILABLE_ATTRIBUTE;

@end

Таким образом попытка создать объект нашего класса извне вызовет ошибку на этапе компиляции. Окей. Теперь и в objective-c у нас есть запрет на создание объектов нашего класса. Правда это еще не совсем приватный инициализатор, но мы к этому вернемся через пару секунд.

Итак, по сути мы получили класс, объекты которого не могут создаваться, потому что конструктор — приватный. И что со всем этим делать? Будем создавать объект нашего класса внутри нашего же класса. И будем использовать для этого статический метод (метод класса, а не объекта):

// Swift
class MyClass {

    private init() {}

    static func shared() -> MyClass {
        return MyClass()
    }

}
// Objective-C
@implementation MyClass

+ (instancetype)sharedInstance {
    return [[MyClass alloc] init];
}

@end

И если для swift опять все просто и понятно, то с objective-c возникает проблема с инициализацией:

Вполне логично, ведь мы сказали ранее, что - (instancetype)init недоступен. И он недоступен в том числе и внутри нашего класса. Что делать? Написать свой приватный инициализатор в файле реализации и использовать его в статическом методе:

// Objective-C
@implementation MyClass

- (instancetype)initPrivate
{
    self = [super init];
    return self;
}

+ (instancetype)sharedInstance {
    return [[MyClass alloc] initPrivate];
}

@end

(да, и не забудьте вынести метод + (instancetype)sharedInstance в файл заголовка, он должен быть публичным)

Теперь все компилируется и мы можем получать объекты нашего класса таким способом:

// Objective-C
[MyClass sharedInstance]
// Swift
MyClass.shared()

Наш синглтон почти готов. Осталось только исправить статический метод так, чтобы объект создавался только один раз:

// Objective-C
@implementation Singleton

- (instancetype)initPrivate
{
    self = [super init];
    return self;
}

+ (instancetype)sharedInstance {
    static Singleton *uniqueInstance = nil;
    if (nil == uniqueInstance) {
        uniqueInstance = [[Singleton alloc] initPrivate];
    }
    return uniqueInstance;
}

@end
// Swift
class Singleton {

    private static var uniqueInstance: Singleton?

    private init() {}

    static func shared() -> Singleton {
        if uniqueInstance == nil {
            uniqueInstance = Singleton()
        }
        return uniqueInstance!
    }

}

Как видите, для этого нам понадобилась статическая переменная, в которой и будет храниться единожды созданный объект нашего класса. Каждый раз при вызове нашего статического метода она проверяется на nil и, если объект уже создан и записан в эту переменную — он не создается заново. Наш синглтон готов, ура! 🙂

Теперь немного примеров из жизни из книги.

Итак, у нас есть шоколадная фабрика и для приготовления мы используем высокотехнологичный нагреватель шоколада с молоком (я просто обожаю молочный шоколад), который будет управляться нашим программным кодом:

// Objective-C

// файл заголовка ChocolateBoiler.h
@interface ChocolateBoiler : NSObject

- (void)fill;
- (void)drain;
- (void)boil;
- (BOOL)isEmpty;
- (BOOL)isBoiled;

@end

// файл реализации ChocolateBoiler.m
@interface ChocolateBoiler ()

@property (assign, nonatomic) BOOL empty;
@property (assign, nonatomic) BOOL boiled;

@end

@implementation ChocolateBoiler

- (instancetype)init
{
    self = [super init];
    if (self) {
        self.empty = YES;
        self.boiled = NO;
    }
    return self;
}

- (void)fill {
    if ([self isEmpty]) {
        // fill boiler with milk and chocolate
        self.empty = NO;
        self.boiled = NO;
    }
}

- (void)drain {
    if (![self isEmpty] && [self isBoiled]) {
        // drain out boiled milk and chocolate
        self.empty = YES;
    }
}

- (void)boil {
    if (![self isEmpty] && ![self isBoiled]) {
        // boil milk and chocolate
        self.boiled = YES;
    }
}

- (BOOL)isEmpty {
    return self.empty;
}

- (BOOL)isBoiled {
    return self.boiled;
}

@end
// Swift
class ChocolateBoiler {

    private var empty: Bool
    private var boiled: Bool

    init() {
        self.empty = true
        self.boiled = false
    }

    func fill() {
        if isEmpty() {
            // fill boiler with milk and chocolate
            self.empty = false
            self.boiled = false
        }
    }

    func drain() {
        if !isEmpty() && isBoiled() {
            // drain out boiled milk and chocolate
            self.empty = true
        }
    }

    func boil() {
        if !isEmpty() && !isBoiled() {
            // boil milk and chocolate
            self.boiled = true
        }
    }

    func isEmpty() -> Bool {
        return empty
    }

    func isBoiled() -> Bool {
        return boiled
    }

}

Как видите — нагреватель сначала заполняется смесью (fill), затем доводит ее до кипения (boil), и после — передает ее на изготовление молочных шоколадок (drain). Для избежания проблем нам нужно быть уверенными, что в нашей программе присутствует только один экземпляр нашего класса, который управляет нашим нагревателем, поэтому внесем изменения в программный код:

// Objective-C
@implementation ChocolateBoiler

- (instancetype)initPrivate
{
    self = [super init];
    if (self) {
        self.empty = YES;
        self.boiled = NO;
    }
    return self;
}

+ (instancetype)sharedInstance {
    static ChocolateBoiler *uniqueInstance = nil;

    if (nil == uniqueInstance) {
        uniqueInstance = [[ChocolateBoiler alloc] initPrivate];
    }

    return uniqueInstance;
}

// other methods

@end
// Swift
class ChocolateBoiler {

    private var empty: Bool
    private var boiled: Bool

    private static var uniqueInstance: ChocolateBoiler?

    private init() {
        self.empty = true
        self.boiled = false
    }

    static func shared() -> ChocolateBoiler {
        if uniqueInstance == nil {
            uniqueInstance = ChocolateBoiler()
        }
        return uniqueInstance!
    }

    // other methods

}

Итак, все отлично. Мы на 100% уверены (точно на 100%?), что у нас есть только один объект нашего класса и никаких непредвиденных ситуаций на фабрике не произойдет. И если наш код на objective-c выглядит довольно неплохо, то swift выглядит недостаточно swifty. Попробуем его немного переписать:

// Swift
class ChocolateBoiler {

    private var empty: Bool
    private var boiled: Bool

    static let shared = ChocolateBoiler()

    private init() {
        self.empty = true
        self.boiled = false
    }

    // other methods

}

Дело в том, что мы можем спокойно хранить наш объект-одиночку в статической константе shared и нам совсем необязательно писать для этого целый метод с проверками на nil. Сам объект будет создан при первом обращении к этой константе и записан в нее один-единственный раз.

А как же многопоточность?

Все будет работать хорошо ровно до того момента, как мы захотим применить в нашей программе работу с потоками. Как же сделать наш синглтон потокобезопасным?

И опять же: в swift, как оказывается, совершенно не нужно выполнять каких-либо дополнительных действий. Константа уже потокобезопасна, ведь значение в нее может быть записано только один раз и это сделает тот поток, который доберется до нее первым.

А вот в objective-c необходимо внести коррективы в наш статический метод:

// Objective-C
+ (instancetype)sharedInstance {
    static ChocolateBoiler *uniqueInstance = nil;

    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        uniqueInstance = [[ChocolateBoiler alloc] initPrivate];
    });

    return uniqueInstance;
}

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

Итоги подведем.

Итак, мы разобрались как правильно писать синглтоны на objective-c и swift. Приведу вам итоговый код класса Singleton на обоих языках:

// Objective-C

// файл заголовка Singleton.h
@interface Singleton : NSObject

- (instancetype)init UNAVAILABLE_ATTRIBUTE;
+ (instancetype)sharedInstance;

@end

// файл реализации Singleton.m
@implementation Singleton

- (instancetype)initPrivate
{
    self = [super init];
    return self;
}

+ (instancetype)sharedInstance {
    static Singleton *uniqueInstance = nil;

    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        uniqueInstance = [[Singleton alloc] initPrivate];
    });

    return uniqueInstance;
}

@end
// Swift
class Singleton {

    static let shared = Singleton()

    private init() {}

}

П.С.

Хочу попросить всех читателей и комментаторов: если вы увидели какую-нибудь неточность или знаете как какой-либо из приведенных мною кусков кода написать правильнее/красивее/корректнее — скажите об этом. Я пишу здесь совсем не для того, чтоб учить других, а для того, чтоб научиться самому. Ведь пока мы учимся — мы остаемся молодыми.

Спасибо вам за внимание.

VIPER

Мы тут на работке потихоньку учим архитектуру VIPER. Поэтому, чтоб не терялись материалы, сложу их тут.

Первое с чего я начал было два видосика от Константина Кокорина:

Для закрепления небольшой пример построения master-detail приложения (от все того же Константина):

Конечно же книжка по VIPER от компании Rambler:
https://habrahabr.ru/company/rambler-co/blog/311248/

Хороший видос с презентацией по архитектуре:

И, самое главное — генератор модулей от того же Рамблера.

Xcode: наверное, лучший способ работы со сторибордами.

Этот пост является вольным переводом статьи Xcode: A Better Way to Deal with Storyboards by Stan Ostrovskiy

Некоторые примеры кода в оригинальной статье устарели (ввиду выхода Swift 3) и в переводе были изменены.

Советы и рекомендации по работе с Interface Builder.

Apple серьезно улучшили Interface Builder в новом Xcode 8. Использование size classes стало более интуитивным, возможность масштабирования сториборда — очень удобной, а полное превью прям в Interface Builder — просто великолепным. Для тех у кого были сомнения насчет использования Interface Builder, это может стать хорошими плюсами.

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

В этой статье я поделюсь некоторыми из лучших практик для работы со сторибордами в вашем проекте. Вы уже пользуетесь Interface Builder, или только делаете первые шаги в этом направлении? — в любом случае, эти советы будут полезны для вас.

1. Если вы работаете в команде, используйте отдельный сториборд для каждого экрана. Даже если вы работаете один — это наверняка станет хорошей привычкой.

В вашем проекте есть один файл main.storyboard, который выглядит вот так?

С точки зрения дизайнера, все хорошо: полностью видно UI и навигацию. И это именно то, для чего Interface Builder и был создан.

Но для разработчика это несет множество проблем:

  • Контроль версий: конфликты слияния сторибордов очень трудно решать, так что работа в отдельных сторибордах сделает жизнь вашей команды проще.
  • Файл сториборда становится объемным и в нем сложно ориентироваться. Как часто вы случайно меняли constraint кликом мышки не в том вью-контроллере?
  • Вам необходимо присваивать каждому вью-контроллеру свой storyboard ID и это может привести к ошибкам: вам нужно «хардкодить» этот ID каждый раз когда хотите использовать этот вью-контроллер в коде.

Как же связать различные сториборды в вашем проекте? Есть два способа.

  1. Используйте ссылки на сториборды (storyboard referencing), которые появились в Xcode 7.

  2. Связывайте сториборды непосредственно в коде.

О первом способе вы можете почитать детальнее здесь.

Я расскажу о втором способе, так как он широко используется для сложных проектов.

2. Используйте одни и те же имена для файла со сторибордом и для связанного класса контроллера (наследника UIViewController).

Это упростит правила именования, а также даст некоторые «плюшки» о которых поговорим в пункте 3.

3. Инициализируйте сториборд непосредственно в классе контроллера.

Когда дело доходит до инициализации вью-контроллера через сториборд, я часто вижу следующий код:

let storyboard = UIStoryboard(name: “Main”, bundle: nil)
let homeViewController = storyboard.instantiateViewController(withIdentifier: “HomeViewController”)

Немного «грязновато»: вам нужно назвать сториборд, вам нужен storyboard ID вью-контроллера, и вам необходимо использовать этот паттерн каждый раз, когда вы создаете HomeViewController.

Лучше перенести этот код в сам класс контроллера и использовать статический метод, чтоб инициализировать контроллер с помощью сториборда:

class HomeViewController: UIViewController { 
    static func storyboardInstance() -> HomeViewController? { 
        let storyboard = UIStoryboard(name: “HomeViewController”, bundle: nil)
        return storyboard.instantiateInitialViewController() as? HomeViewController    
    }
}

Если вы последуете предыдущему совету (одинаковые имена файлов), то можете избежать «харкода» имени сториборда и воспользоваться String(describing:):

let storyboard = UIStoryboard(name: String(describing: self), bundle: nil)

Убедитесь, что у файла сториборда такое же имя как и у класса контроллера. Иначе ваше приложение будет «крэшится» когда вы попытаетесь создать ссылку на такой сториборд.

Это делает ваш код более читаемым и отказоустойчивым:

class HomeViewController: UIViewController {
    static func storyboardInstance() -> HomeViewController? { 
        let storyboard = UIStoryboard(name: String(describing: self), bundle: nil)
        return storyboard.instantiateInitialViewController() as? HomeViewController 
    }
}

Если вы хотите иметь доступ к вью-контроллеру через instantiateInitialViewController() убедитесь, что вы указали этот вью-контроллер как initialViewController в Interface Builder. Если у вас несколько вью-контроллеров на одном сториборде, вам придется использовать instantiateViewController(withIdentifier: _ )

Теперь, инициализация такого вью-контроллера займет одну строку:

let homeViewController = HomeViewController.storyboardInstance()

Просто и понятно, не так ли?

Вы можете использовать этот же подход для инициализации вью из nib:

class LoginView: UIView {
    static func nibInstance() -> LoginView? {
        let nib = Bundle.main.loadNibNamed(String(describing: self), owner: nil, options: nil)
        return nib?.first as? LoginView
    }
}
4. Не перегружайте свой проект переходами на сториборде.

У вас не будет переходов, если вы последуете совету из пункта 1. Но даже если у вас есть несколько вью-контроллеров в одном сториборде, использование переходов (segues) для навигации между ними — не очень хорошая идея:

  • Вам нужно дать имя каждому переходу (segue), что само по себе может привести к ошибкам. «Хардкодить» строки с именами — плохая практика.
  • Метод prepareForSegue будет просто нечитаем, когда вы будете работать в нем с несколькими segue, используя операторы ветвления if/else или switch.

Какова альтернатива? Когда вы хотите перейти к следующему вью-контроллеру по нажатию на кнопку, просто добавьте IBAction для этой кнопки и инициализируйте вью-контроллер в коде: это ведь всего одна строка, как вы помните из пункта 3.

@IBAction func didTapHomeButton(_ sender: AnyObject) {
    if let nextViewController = NextViewController.storyboardInstance() {
        // initialize all your class properties
        // nextViewController.property1 = … 
        // nextViewController.property2 = … 

        // either push or present the nextViewController,
        // depending on your navigation structure 
        // present(nextViewController, animated: true, completion: nil) 

        // or push  
        navigationController?.pushViewController(nextViewController, animated: true)
    }
}
5. Unwind segue? Не, не слышал.

Иногда навигация предполагает возврат пользователя к предыдущему экрану.

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

Начиная с iOS 7, Interface Builder дает вам возможность сделать «unwind» навигационного стэка.

Unwind segue позволяет вам указать возврат на предыдущий экран. Это звучит довольно просто, но на практике это требует некоторых дополнительных действий и только сбивает с толку разработчика:

  • Обычно, когда вы создаете действие для кнопки (action), Interface Builder создаст для вас код (IBAction). В этом же случае, ожидается, что код уже написан до того, как вы зажмете «Ctrl» и перетащите действие от вашей кнопки к «Exit».
  • Обычно когда вы создаете действие для кнопки, код этого действия создается в том же классе, которому и принадлежит кнопка. Для Unwind Segues, вам нужно писать код в классе того вью-контроллера, в который этот переход произойдет.
  • Метод prepareForUnwind будет иметь все те же недостатки, что и метод prepareForSegue (см. предыдущий пункт).

Каков же более простой способ?

Проще делать это в коде: вместо создания действия «unwind» для вашей кнопки, создайте обычный IBAction и используйте dismissViewController или popViewController (в зависимости от вашей навигации):

@IBAction func didTapBackButton(_ sender: AnyObject) { 
    // if you use navigation controller, just pop ViewController:
    if let nvc = navigationController {   
        nvc.popViewController(animated: true)
    } else {
        // otherwise, dismiss it
        dismiss(animated: true, completion: nil)
    }
}

На сегодня это все. Я надеюсь, вы найдете что-то полезное для себя.

От переводчика:

Благодаря методу описанному в этой статье, я очень сильно упростил работу со сторибордами в своем текущем проекте Zumme. Пока я работал над ним один — все было прекрасно, но как только появились другие разработчики — работа со сторибордом превратилась в настоящий ад. От отчаянья мы практически перешли к «банановому методу» (можно почитать здесь в разделе «Pass the banana»).

Конечно же, в идеале нужно будет рано или поздно прийти к VIPER. Но об этом будет уже другой пост. 🙂