Заметки об архитектуре приложений (iOS)

 
   

В последнее время часто стали появляться статьи о том, как же правильно строить архитектуру вашего iOS приложения. Много разного шума о том, что MVC - это не Model View Controller, а Massive View Controller. Стали появляться специализированные сайты, где авторы пытаются подробно разжовывать что и как вы должны делать, чтобы архитектура вашего приложения была правильная и всем было хорошо.

Небольшой реестр того, что заслуживает внимание

  • Clean Swift, eng - тут вам предлагают рецепты (в виде шаблонов xCode), которые помогут вам управлять архитектурой правильно. Основано на VIPER.
  • Архитектурные паттерны в iOS, рус - статья на хабре (переводная) где автор разбирает доступные архитектурные паттерны. Полезно почитать для понимания и знания того, что значит MV… (Model View Something)
  • Clean Architecture, eng - статья Uncle Bob - основоположника Clean Architecture на базе которого вырос VIPER.

И много еще разного, тут привел что мне самому показалось полезным. Архитектурный паттерн VIPER попытались реализовать и популяризировать. Вот тут ребята даже наваяли автогенератор классов-модулей для VIPER для вашего приложения на Swift.

Однако во всем этот шуме очень мало попыток разобраться и что называется “на пальцах” проработать эти вещи. Да, Clean Swift заслуживает внимания, но он на английском и там автор все таки пытается продвигать свои xCode шаблоны. Статья uncle bob заслуживает внимания, но требует ооооочень вдумчивого прочтения. Я на истину и “бронзу” не претендую, но все же представлю свою “видение” этого всего и попытаюсь насколько это можно говорить простым языком. Итак, приступим. Для начала картинка:

ARCH

Пояснение к объектам:

  • View - представление, то что непосредственно рисуется на экране. В терминах iOS - это все UIView. Вся задача этой компоненты - нарисоваться на экране правильно.
  • ViewController - контроллер представления, ответственнен за управление представлением, правильного заполнения его полей и является делегатом для событий от представления. В терминах iOS - это все UIViewController. Вся задача этой компоненты - управлять представлением, однако бизнес-логика не для этого слоя.
  • Interactor - компонента которая обрабатывает события, как пользовательские (пользователь нажал кнопку), так и системные (экран появился или вот-вот появится). В терминах iOS нет специального класса для этого, этот класс полностью в вашем ведении. Данные компонент обеспечивает также работу с источником данных и с компонентом маршрутизатора.
  • Presenter - компонент, обеспечивающий связь миров бизнес-логики и представления. Обеспечивает в том числе преобразование из слоя данных в слой представления (Entity -> ViewModel). Также делает представление-специфичную обработку данных (например, группировка для UITableView). В терминах iOS также нет особых классов для этого компонента.
  • Router - маршрутизатор, обеспечивает смену связки V.I.P. (View-Interactor-Presenter) и управляет глобальными вещами для приложения (настройки, авторизация, может быть некоторая обработка ошибок и т.д.). В терминах iOS также нету специального класса, но источником pushViewController:animated: должен быть именно маршрутизатор.

За кадром остались

  • Configurator, компонент который связывает все воедино и может быть точкой внедрения DI
  • Initializer, специальный объект, который дает вам точку входа для вызова конфигуратора, если вы работаете через Storyboard’ы например.

Кроме того, не все линии одинакового цвета, это тоже кой чего значит:

  • оранжевые линии - тесная связь UIView и UIViewController. С точки зрения ViewController’а в сторону View (“управляет”) - хранит ссылки и обеспечивает настройку через вызовы сеттеров или просто функций. С точки зрения View в сторону ViewController’а (“события”) - ViewController является делегатом для View и является первой точкой приема событий.
  • красные линии - преобразованные в специальные DataTransfer Objects (DTO) “события” от представления, которые должны быть обработаны бизнес-логикой (т.е. в нашем случае интерактором). Это могут быть как просто скалярные объекты (числа, строки и т.д.), так и простые (POCO, Swift struct, …) объекты. Главное чтоб эти объекты не организовывали протекающие абстракции.
  • синии линии - объекты, которые понимает интерактор, которые необходимо отобразить в слое представления. Интерактор по понятным причинам не может и не должен знать о том, как устроено представление, поэтому он передает презентеру объекты для того, чтобы презентер их правильно преобразовал и отобразил на представлении.
  • зеленые линии - объекты (ViewModel) представления, которые понимает контроллер и которые контроллер использует для настройки вьюшек.
  • черные линии - взаимодействие интерактора со специфичными сервисами (сеть, CoreData и т.д.) и передача команд маршрутизатору, когда команда находится в ведении маршрутизатора или какого-то другого интерактора.

Таким образом

  1. Представление (V), интерактор (I) и презентер (P) образуют цикл. Данные могут передаваться только в направлении указанном стрелочками. Благодаря тому, что поток данных однонаправленный, они могут быть иммутабельными, что хорошо сказывает на многопоточности например.
  2. Представление, интерактор и презентер напрямую друг о друге ничего не знают - они взаимодействуют через интерфейсы (на схеме обозначены зелеными квадратами). Это позволяет их заменять по отдельности, тестировать их по отдельности а также открывает путь к реализации паттерна DI в рамках конфигуратора.

To be continue… (Статья будет пополняться, можете принять в этом участие)

Конжак 2016

 

Решили мы тут попробовать свои силы и таки взобраться на Конжак. “Конжаком” в народе называют гору Конжаковский камень - самую высокую гору Свердловской области (не путать с Уралом, на Серверном Урале есть горы и повыше). Каждый год здесь проводится горный марафон “Конжак”, где участникам предлагается на время бегом взобраться на вершину, там отметиться и вернуться на старт. Ну а раз проводиться марафон, то по маршруту марафона организованы контрольные пункты, и на трассе присутствует огромное количество людей - не так страшно в безлюдных горах. Марафон, как правило, проводят с 10 часов (дают старт забегу) и до 8 вечера. Вообще на трассе находятся два вида участников: “спортсмены” - те, кто официально участвует в марафоне, и “туристы” - те, кто путаются под ногами забираются в гору самостоятельно и не участвуют в марафоне.

Итак, как сообщает нам википедия:

Конжаковский Камень — гора в южной части Северного Урала, на территории Свердловской области (Россия). Одна из высочайших вершин Уральских гор (1569 м). Сложена пироксенитами, дунитами и габбро. В нижней части склоны покрыты хвойными лесами, а с высоты 900—1000 м — горной тундрой и каменными россыпями[1]. Названа по имени охотника-вогула Конжакова, юрта которого некогда стояла у основания горы[2].

Вдоль склонов горы проложена трасса марафона, на 4 километре которой есть даже вот такая его карта

карта марафона

Рассчитывая свои силы мы решили пойти пораньше, так как мы совсем не марафонцы. Начало не предвещало того, что потом открылось нам:

это только начало

По дороге нам встретился вполне горный горный перевал, а потом дорога пошла вниз (мы удивились - в гору же идем) и привела нас к плещущейся горной реке и наведенной через нее переправе:

горная речка - деревянный мост

Чуть выше по маршруту на нее можно посмотреть с высоты:

горная речка - смотровая площадка

Дальше начался подъем вверх, сначала пологий, но потихоньку он становился все круче и круче. После небольшого подъема (примерно 2 км) встречается КП “Родник”, где “спортсменов” кормят бутербродами и поют чаем, а “туристы” могут пополнить запасы воды в роднике. Вода, кстати, вкусная. Далее после небольшого горизонтального участка трасса круто забирает вверх. На этом подъеме нас догнали первые “спортсмены”, которые в отличии от нас стартовали не в 8, а в 10. Далее, после еще нескольких километров крутого подъема, встречается второй контрольный пункт - “Поляна Художников”. Это отметка 14 км от начала трассы и, как правило, многие “туристы” заканчивают здесь свое восхождение (высота около 1000 м над уровнем моря). Но мы хотели взобраться совсем наверх, поэтому пошли дальше.

поляна художников (спойлер - обратите внимание на муху в центре кадра)

С этой поляны открываются впечатляющие виды

панорама с поляны художников

Следующим впечатлящим пунктом стал ледник, который мы запреметили еще издалека

ледник

Ледник уже сам по себе находится достаточно близко к первым вершинам (еще не сам Конжак):

ледник 2

А вот с него уже деревья не мешают обзору, и вид вообще шикарный:

виды с ледника

Дальше, после длительного карабканья по камням, мы попадаем на Иовское плато, где настолько высоко, что уже немного тяжело дышать, и ветер дует постоянно. А еще здесь как на болоте - почва утопает под ногами, и пройти в кросовках, не промочив ноги, практически невозможно.

Иовское плато

Здесь начинаются обещанные альпийские луга (где, кстати, очень много растений из Красной книги)

Альпийские луга

Виды с Иовского плато

Виды с Иовского плато

Здесь, посмотрев на почти отвесный подъем по камням на саму вершину:

Упс

Мы решили на этом закруглиться. Итого 1246 метров из 1569, не дошли чуть больше 200 метров в высоту и 2,5 км по трассе в длину.

Пруф Пруф 2

Вид с конечной точки нашего восхождения

Конечная точка

“Дохлости”

Дохлости

Далее мы пошли вниз. В итоге - 39,46 километров мы прошли (марафон был 42) сделав 36 тысяч шагов.

Пруф 3

Полезные флаги xCode

 
   

Некоторые полезные в работе флаги

  • -UIViewShowAlignmentRects YES - показывает желтыми линиями frame rect’ы, которые удобно использовать для отладки
  • -com.apple.CoreData.ConcurrencyDebug 1 - “стреляет” assert’ами, когда NSManagedObjectContext или другие примитивы CoreData используются в неправильном потоке
  • -com.apple.CoreData.SQLDebug 1 - пишет в лог все sqlite запросы, которые делает CoreData в процессе своей работы

GitHub - синхронизируем forked репозиторий с upstream

 

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


# Добавляем новый удаленный репозиторий
$ git remote add upstream https://github.com/whoever/whatever.git

# Загружаем все ветки для отслеживания
$ git fetch upstream

# Убеждаемся, что мы на своем master'е
$ git checkout master

# Перезаписываем нашу ветку master чтобы те наши коммиты, которые не входят в удаленный мастер
# оказали на нем:
$ git rebase upstream/master

# Force push необходим для перезаписи истории ветки на вашем репозитории
$ git push -f origin master

Поваренная книга GCD

   

GCD - Grand Central Dispatch - библиотека от Apple для iOS и OS X для работы с примитивами многозадачности. На просторах интернета наткнулся на статью с некоторыми рецептами по работе с GCD. Статья на английском и очень интересная. Здесь хочу предложить вам ее вольный перевод. Далее повествование ведется от лица автора.

Grand Central Dispatch или GCD - очень мощная штуковина. Она предоставляет в ваше распоряжение низкоуровневые конструкции, такие как очереди и семафоры, которые вы можете комбинировать различными путями для получения многотопоточных эффектов. Однако, основанное на языке С API, на первый взгляд кажется книгой заклинаний и не сразу понятно, как собрать эти низкоуровневые кубики в нечто полезное высокоуровневое. В этой статье я постараюсь описать некоторые полезные конструкции, которые вы сможете использовать в своих приложениях.

Выполнение задач в фоне

Пожалуй самое просто что может потребоваться - это выполнить некоторую работу в фоновом потоке, а затем вернуть результат в основной поток для отображения, так как такие компоненты как UIKit могут работать исключительно из главного потока.

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

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

let defaultPriority = DISPATCH_QUEUE_PRIORITY_DEFAULT
let backgroundQueue = dispatch_get_global_queue(defaultPriority, 0)
dispatch_async(backgroundQueue) {
  let result = doSomeExpensiveWork()
  dispatch_async(dispatch_get_main_queue()) {
    // используем как-нибудь `result`
  }
}

На практике, очень редко используются другие приоритеты, нежели DISPATCH_QUEUE_PRIORITY_DEFAULT. Функция dispatch_get_global_queue возвращает очередь, задания из которой могут выполняться на сотнях разных потоков. Если вам требуется, чтобы какие-то тяжелые задачи всегда выполнялись на какой-то определенной фоновой очереди, вы можете создать свою с помощью dispatch_queue_create. Она принимает имя очереди и флаг того, должна быть очередь последовательной (serial) или конкурентной (concurrent).

Важно, чтобы каждый вызов использовал dispatch_async, а не dispatch_sync. dispatch_async возвращает управление до того момента, как блок будет выополнен. Напротив, dispatch_sync дожидается выполнения блока перед тем как вернуть управление. Внутренний вызов в dispatch_sync может использовать dispatch_sync (так как в общем все равно когда вернется управление), но внешний вызов должен быть dispatch_async, потому что иначе главный поток будет заблокирован.

Singleton (паттерн одиночка)

С помощью dispatch_once вы можете создавать объекты паттерна “одиночка”. В swift это уже не так важно, так как есть более простые способы, однако для информации все же паттерн приводится ниже (ну и для Objective-C это по прежнему важно).

+ (instancetype) sharedInstance {  
  static dispatch_once_t onceToken;  
  static id sharedInstance;  
  dispatch_once(&onceToken, ^{  
    sharedInstance = [[self alloc] init];  
  });  
  return sharedInstance;  
}  

Сглаживаем О_О блок обратного вызова

Сглаживание конечно не совсем верный перевод для термина flatten, который еще можно перевести как выравнивание, но за неимением лучшей альтернативы пока так, можете предложить лучше, если знаете. (прим. переводчика)

А вот тут уже становится интересно. С помощью семафора, мы можем заблокировать поток на некоторое количество времени, пока не получим сигнал от другого потока. Семафоры, как и другие примитивы GCD, потокобезопасные, поэтому их можно вызывать где угодно.

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

// на фоновой очереди
dispatch_semaphore_t semaphore = dispatch_semaphore_create(0)
doSomeExpensiveWorkAsynchronously() {
    dispatch_semaphore_signal(semaphore)
}

dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER)
// тяжелая асинхронная обработка закончена

С помощью вызова dispatch_semaphore_wait вы блокируете поток до тех пор, пока где-либо не будет вызван сигнал dispatch_semaphore_signal. Это означает, что сигнал должен быть вызван из другого потока, так как текущий заблокирован. И более, вы никогда не должны вызывать wait из главного потока, только из фонового.

Вы можете выбрать любой промежуток времени при вызове dispatch_semaphore_wait (это будет своего рода таймаут), но обычно передается DISPATCH_TIME_FOREVER.

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

typealias DoneBlock = () -> ()
typealias WorkBlock = (DoneBlock) -> ()

class AsyncSerialWorker {
  private let serialQueue = dispatch_queue_create("com.khanlou.serial.queue", DISPATCH_QUEUE_SERIAL)

  func enqueueWork(work: WorkBlock) {
    dispatch_async(serialQueue) {
      let semaphore = dispatch_semaphore_create(0)
      work({
        dispatch_semaphore_signal(semaphore)
      })
      dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER)
    }
  }
}

Этот небольшой класс создает последовательную очередь и позволяет вам передать в нее блок на обработку. Объект WorkBlock дает вам DoneBlock для вызова, когда работа будет закончена, который в свою очередь отправит сигнал семафору и очередь отправит на обработку следующий блок.

Ограничиваем количество одновременно выполняемых блоков

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

class LimitedWorker {
  private let concurrentQueue = dispatch_queue_create("com.khanlou.concurrent.queue", DISPATCH_QUEUE_CONCURRENT)
  private let semaphore: dispatch_semaphore_t

  init(limit: Int) {
    semaphore = dispatch_semaphore_create(limit)
  }

  func enqueueWork(work: () -> ()) {
    dispatch_async(concurrentQueue) {
      dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER)
      work()
      dispatch_semaphore_signal(semaphore)
    }
  }
}

Этот пример взят из Apple Concurrency Programming Guide. Они могут объяснить что происходит лучше чем я:

Когда вы создаете семафор, вы можете задать количество доступных ресурсов. Это значение становится первичным значением счетчика для семафора. Каждый раз когда вы вызываете wait на семафоре, dispatch_semaphore_wait уменьшает счетчик на единицу. Если полученное значение отрицательно, функция блокирует ваш поток. С другой стороны, вызов функции dispatch_semaphore_signal увеличивает счетчик на единицу для уведомления о том, что ресурс освобожден. Если есть заблокированные задачи, ожидающие доступ к ресурсу, одна из них разблокируется и начнет выполнение.

Эффект аналогичен установке значения maxConcurrentOperationCount объекта NSOperationQueue. Если вы используете сырой GCD вместо NSOperationQueue, вы можете использовать семафоры для ограничения количества одновременно выполняемых блоков.

ВАЖНО! Здесь присутствует один важный момент. Каждый раз когда вы вызываете enqueueWork и вы достигли ограничения семафора - создается новый поток. Если у вас небольшой лимит и много задач для размещения в очередь, вы таким образом создадите огромное количество заблокированных потоков. Используйте профайлер и избегайте узких мест.

Ожидаем единого завершения нескольких асинхронных операций

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

dispatch_group_t group = dispatch_group_create()
for item in someArray {
  dispatch_group_async(group, backgroundQueue) {
    performExpensiveWork(item: item)
  }
}

dispatch_group_notify(group, dispatch_get_main_queue()) {
  // все задачи выполнены
}

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

// этот код должен выполняться на фоновом потоке
dispatch_group_t group = dispatch_group_create()
for item in someArray {
  dispatch_group_enter(group)
  performExpensiveAsyncWork(item: item, completionBlock: {
    dispatch_group_leave(group)
  })
}

dispatch_group_wait(group, DISPATCH_TIME_FOREVER)

// все фоновые задачи в группе выполнены

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

Последнее в этом примере - это вызов wait, который блокирует поток и ожидает момента когда счетчик достигнет 0. Важно! Вы моежет использовать dispatch_group_notify даже если вы используете enter/leave. Обратное также верно - вы можете использовать dispatch_group_wait даже при использовании dispatch_group_async.

Функция dispatch_group_wait также как и dispatch_semaphore_wait принимает в качестве аргумента таймаут. Опять же, редко возникает нужда в чем-то отличном от DISPATCH_TIME_FOREVER. И так же как и в случае dispatch_semaphore_wait никогда не вызывайте dispatch_group_wait на главном потоке.

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

Очереди изоляции

Словарь (и массив) в Swift - типы значений. Когда они изменяются, их ссылка полностью заменяется новой копией структуры. Однако, ввиду того что обновление полей экземпляра (ivar) объектов Swift не является атомарной операцией, она не является потокобезопасной. Два потока могут обновить словарь (например, добавить значение) в одно и то же время, и оба попытаются записать один и тот же блок памяти, что приведет к ее повреждению. Для того, чтобы обеспечить потокобезопасность можно использовать очереди изоляции.

Давайте сформируем коллекцию объектов, которая будет представлять собой словарь, где ключом будет ID объекта, а значением - сам объект.

class IdentityMap<T: Identifiable> {
  var dictionary = Dictionary<String, T>()

  func object(forID ID: String) -> T? {
    return dictionary[ID] as T?
  }

  func addObject(object: T) {
    dictionary[object.ID] = object
  }
}

Этот объект фактически является оберткой над словарем. Если наша функция addObject будет вызвана из нескольких потоков в одно и то же время, память будет нарушена, так как потоки будут использовать одну и ту же ссылку. Эта известная задача о читателях-писателях. В кратце, мы можем иметь несколько читателей в одно и то же время, но только одного писателя в каждый конкретный момент времени.

К счастью, GCD дает нам замечательные инструменты для этого случая. Нам доступны следующие инструменты для решения этой задачи:

  • dispatch_sync
  • dispatch_async
  • dispatch_barrier_sync
  • dispatch_barrier_async

Идеальным случаем будет:

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

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

class IdentityMap<T: Identifiable> {
  var dictionary = Dictionary<String, T>()
  let accessQueue = dispatch_queue_create("com.khanlou.isolation.queue", DISPATCH_QUEUE_CONCURRENT)

  func object(withID ID: String) -> T? {
    var result: T? = nil
    dispatch_sync(accessQueue) {
      result = dictionary[ID] as T?
    }
    return result
  }

  func addObject(object: T) {
    dispatch_barrier_async(accessQueue) {
      dictionary[object.ID] = object
    }
  }
}

Функция dispatch_sync отправит блок в нашу очередь изоляции и будет дожидаться окончания перед тем как вернуть выполнение. После этого, мы будем иметь результат нашего чтения. Если не делать вызов синхронным, тогда потребуется введение блока обратного вызова. Благодаря тому, что очередь конкурентная, такие синхронные чтения могут выполняться по несколько штук параллельно.

Функция dispatch_barrier_async отправит блок в очередь изоляции. Async означает что управление будет возвращено до того, как блок фактически выполниться (т.е. выполниться запись). Это хорошо для проиводительности, но имеет и обратную сторону медали - чтение сразу после записи может вернуть старые данные.

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

Post Scriptum

Фреймворк GCD содержит много низкоуровневых примитивов. С их помощью мы смогли построить высокоуровневые конструкции. Если вам известны какие-то другие высокоуровневые конструкции не упомянутые здесь, будут рад их услышать (вы можете сделать PR тут или отправить напрямую автору. прим. переводчика)