Паттерны проектирования, взгляд 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, о котором я упоминал в самом начале статьи, такой проблемы не существует. Дело в том, что там передача данных происходит одним-единым параметром-словарем.

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

Реклама

Через MVP к VIPER. Часть первая: MVP


Не так давно я публиковал перевод Почему VIPER это плохой выбор для вашего следующего приложения. И, как это часто бывает, мнение переводчика не совпало с мнением автора оригинальной статьи. Поэтому в этом посте я коротко расскажу как я пытаюсь (пока еще пытаюсь) внедрить VIPER в свои проекты.

Когда я начал работу над своим предыдущим проектом, в команде было ровно два целых ноль десятых мобильных разработчика: один писал версию под Андроид, второй – под iOS.

Естественно, iOS версия создавалась на классическом, рекомендуемом самим Apple, паттерне MVC.

У меня была View: «любимый» сториборд, в котором было over9000 довольно много экранов, и который был похож на это:

У меня была модель – классы, для хранения настроек, данных пользователя, некоторых данных, которые сохранялись во внутренней БД.

И у меня был вью-контроллер для каждого экрана на сториборде. Во вью-контроллере должны были размещаться обработчики действий пользователя, а также уведомления от модели (получение данных по запросу или изменения данных). Но на самом же деле там происходило нечто вот такое:

Networking.request(.POST, withURL: serverAddress, andParameters: parameters) { (dictResult, error) in
    if let dictResult = dictResult {

        // some actions with view

        if let rates = dictResult["rates"] as? [AnyObject] {
            for rate in rates {
                if let rateDictionary = rate as? JSONDictonary {
                    let result = Result(jsonDictionary: rateDictionary)
                    self.resultsArray.append(result)
                }
            }
        }

        // some actions with view

    } else {
        if let error = error {
            DispatchQueue.main.async(execute: {
                self.showError(error)
            })
        }
    }
}

В общем: я уверенно двигался от Model View Controller к Massive View Controller и с этим нужно было что-то делать.

Я начал изучать возможные архитектуры для мобильных приложений, и конечно, мне сразу же понравился паттерн Clean architecture (он же, по сути – VIPER):

картинка из The Book of VIPER

Я смело потратил несколько выходных на изучение этого паттерна. Просмотрел несколько видео-туториалов и написал немало простых приложений-примеров на нем. Также нашел удобные скрипты для генерации фалов модулей, ведь один модуль это – около десятка файлов (протоколы, конфигураторы, непосредственно классы, сториборды, юнит-тесты). Но довольно быстро пришло понимание, что если я сейчас попробую внедрить VIPER “в лоб”, то потеряю массу времени. Ведь с непривычки, у меня будут слишком большие временные затраты на правильное разделение ответственности между всеми слоями. И я решил пойти путем попроще и разделять ответственность постепенно.

А что если я скажу тебе, что UIViewController — это View.

Именно поэтому я взялся за MVP. Самое сложное и в то же время – самое простое – понять, что UIViewController – относится к слою View. Он должен работать с UI и только с ним: принимать от пользователя действия связанные с UI (нажатия кнопок, ввод текста, etc.) и менять отображение самого UI. Сначала это казалось сложным, но я вывел для себя несколько простых правил, которые помогают положить во вью-контроллер все необходимое, и при этом не положить туда ничего лишнего:

  1. Все методы и алгоритмы вью-контроллера должны работать с UI. Если вы видите какой-то метод или кусок кода, который может работать без использования UIKit, знайте: этому методу/блоку кода здесь не место.

  2. Для презентера, все в точности наоборот: там не должно быть ни одного метода для выполнения которого нужен UIKit. Если такие методы обнаружены – нужно внимательно изучить эти участки кода и вынести их во вью-контроллер.

  3. У вью-контроллера и презентера есть протоколы на которые они подписаны, это их внешние интерфейсы. Там определены методы, которые можно вызывать для них извне. Поэтому, если вдруг, вы видите во вью-контроллере вызов метода из интерфейса (протокола) этого же вью-контроллера – этот метод должен находиться не во вью-контроллере, а в презентере.

Переход на MVP, научил меня главной вещи: разделять ответственность между сущностями. Пока что, я только отделил работу с UI от всего остального, но впереди, меня все-таки ждет VIPER. Возможно классический, а возможно и более многослойный.

Я надеюсь, что скоро я до него доберусь и продолжу свой рассказ.

«Мочим» объекты с помощью 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

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