Привет! Я Александра Башкирова, iOS-инженер в Clover и старший код-ревьюер на курсе «iOS-разработчик» в Яндекс Практикуме. На момент подготовки статьи мы уже проверили более тысячи студенческих работ и успели заметить повторяющиеся ошибки.

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

Часть 1. Вёрстка

Мы учим студентов верстать в Storyboard — это самый быстрый способ получить готовое и работающее приложение. Вам не нужно особых знаний для того, чтобы создать несколько стандартных элементов в Interface Builder и расположить их как нужно. Интерфейс достаточно простой и чем-то даже напоминает создание работ в Paint.

Связь между Storyboard и кодом

Многим новичкам в iOS-разработке непросто понять взаимосвязь элементов Storyboard с кодом. Важно знать, что из себя представляет эта связь и за чем стоит следить в первую очередь.

Связывание графических элементов с кодом происходит с помощью аутлетов (outlets) и экшенов (actions). @IBOutlet — это ссылка на графический элемент (например, кнопка или текстовое поле) в классе вью-контроллера или вью, которая позволяет получить доступ к его свойствам и методам. @IBAction — это метод в классе вью-контроллера или вью, который вызывается при определённом действии пользователя (например, при нажатии на кнопку).

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

Ошибки и решения

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

this class is not key value coding-compliant for the key helloLabel.

Приложение упало в runtime и сообщило, что не нашло подходящего значения для ключа helloLabel.

В примере я переименовала helloLabel в label

helloLabel — это название элемента в момент, когда мы создавали связь со сторибордом. Дело в том, что в сториборде эта связь осталась неизменной, хотя во вью-контроллере тот же элемент уже называется label. Storyboard вполне текстовый документ, его можно открыть в виде XML-документа и найти лейбл, описанный в таком блоке:

<connections>
   <outlet property="helloLabel" destination="jeX-w7-wVM" id="hGw-e6-vqM"/>
</connections>

Получается, что Storyboard не узнал о том, что мы поменяли название ссылки на элемент. Чтобы решить эту проблему, нужно привести название к одинаковому состоянию в коде и в Storyboard. Для этого можно удалить старую связь с аутлетом в Connection Inspector и создать новый аутлет либо поменять название в коде на то, которое понимает ваш Storyboard.

Когда вы меняете название аутлета или экшена в коде, нужно гарантировать, что это же название поменялось и в вашем Storyboard. К счастью, таких проблем можно избежать, если для изменения названий пользоваться встроенным в Xcode рефакторингом Refactor → Rename — тут Xcode подскажет, что нужно обновить имя ссылки в сториборде тоже.

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

Ошибки нас ждут также, если удалим в Connection Inspector связь у Storyboard с кодом, но продолжим обращаться к оставшимся аутлетам из кода. @IBOutlet создаётся неявным опционалом, потому что сама инициализация элемента не может быть сделана в момент создания контроллера. Инициализация будет выполнена системой позже, а если такой связи на сториборде нет, то не будет выполнена никогда.

Попробуем обновить значение в helloLabel, но удалим связь в Storyboard: как только приложение дойдёт до строчки с установкой значения — произойдёт краш в runtime. Мы попробовали установить свойство text несуществующему объекту, и такое обращение было небезопасным, ведь свойство было неявным опционалом.

Обращайте внимание на логи в дебаг-панели. Если приложение упало, там будет подсказка, в чём причина
Обращайте внимание на логи в дебаг-панели.
Если приложение упало, там будет подсказка, в чём причина

В этом примере Xcode подсказывает пустым кружочком рядом с аутлетом, что связи с элементом нет. Если мы хотим поменять значение лейбла на Хабр, привет!, то нужно будет восстановить ссылку на него, добавив связь заново. Например, соединив кружочки напротив new referencing outlet со свойством helloLabel.

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

  1. Совпадение названий элемента или функции в коде и в сториборде.

  2. Наличие связи с кодом у элемента на вкладке Connection Inspector.

Использование Auto Layout

Пожалуй, самая типичная ошибка новичка — создать красивый интерфейс для новенького iPhone Pro Max, но не адаптировать его под размеры других девайсов.

Адаптивный интерфейс верстаем с помощью Auto Layout. В Storyboard есть целый набор различных способов, как привязать элементы к краям других элементов или ограничить размеры View.

На старте может быть сложно подружиться с Auto Layout. Ведь важно не только научиться пользоваться базовыми инструментами, но и понять, почему Auto Layout работает именно так и как заставить его делать то, что вам нужно.

Математическое представление Constraint
Математическое представление Constraint

Важное свойство системы неравенств: она может иметь несколько решений. Именно поэтому возникают ошибки и предупреждения в Interface Builder, а также предложение «Добавить недостающие констрейнты». Из-за этого можно получить совершенно неожиданный результат: предполагаешь одно конкретное решение, а в ходе расчёта Auto Layout может подобрать совершенно другое решение неравенств :)

Изучив основы Auto Layout, можно переходить к работе с Figma.

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

Например, на скриншотах выше можно задать серой View размер 335×502, что будет попадать в макет для iPhone X, но, вероятно, это будет многовато для iPhone SE 1-го поколения. Поэтому нужно определить в макете отступы у серой View сверху, слева, справа и снизу относительно блока с текстом. Или можно задать отступ сверху от края экрана 590, а не от соседней серой View, что точно вытолкнет её на самый низ экрана для iPhone 8 (а у нас там ещё кнопки должны расположиться). Поэтому правильно в этом случае задавать отступ для текста сверху от серой View, а снизу — от кнопок.

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

Пара слов о горизонтальных отступах

Новичкам сложно запомнить названия leading и trailing для горизонтальных привязок слева и справа. Возникает вопрос, почему Apple не назвала их left и right? На самом деле у Auto Layout есть привязки (они же anchors), которые так и называются left и right. Но Apple не просто так ввела понятия leading и trailing для описания границ элементов. Положения leading и trailing могут быть различными в зависимости от текущей Locale, и для арабской локали или иврита приложение с помощью таких констрейнтов будет корректно отображено с зеркальным размещением элементов. Если же вы будете использовать привязки left и right, то Auto Layout не будет переворачивать интерфейс для RTL-локалей. Мы советуем прислушиваться к рекомендации Apple и использовать leading и trailing, а запомнить их можно так: leading — ведущий, а trailing — замыкающий. Ведущим мы начинаем верстать, а замыкающим — заканчиваем.

И пара слов о вертикальных отступах

Уже несколько лет Apple выпускает iPhone с чёлкой и закруглёнными краями экрана, на которых расположены элементы системы Status Bar и Home Indicator. Пользователю должно быть удобно с ними взаимодействовать. Это означает, что мы, как разработчики приложений, не должны размещать свой UI в этих областях, чтобы не мешать пользователю. Для этого на вью экрана существует специальный гайд (layout guide), который описывает безопасную область — Safe Area.

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

  • 34 пункта от нижнего края View,

  • 34 пункта от нижнего края Safe Area.

И оба варианта неверны.

  • Первый вариант даст небольшую свободную область: там расположится Home Indicator. Но это заблуждение, во-первых, потому что существуют девайсы с прямоугольным экраном и физической кнопкой Home, для которых это пространство будет ненужным. Во-вторых, нет гарантии, что Apple не поменяет интерфейс iOS и размер под безопасное пространство рядом с Home Indicator, поэтому жёстко фиксироваться на значении 34 нельзя.

  • Второй вариант происходит из-за невнимательности: по умолчанию сториборды делают любую привязку к краям главной View экрана сразу к Safe Area, и поставить значение 34 — значит увеличить область снизу в два раза (верно для девайсов, у которых эта область уже 34).

Поэтому разработчикам нужно быть внимательными к вертикальным отступам у экрана в области Safe Area и в Figma обязательно отделять визуально отступы от неё, а не от края макета. В примере выше кнопки прикреплены к краю Safe Area, и их стоит прикрепить к ней с нулевым отступом.

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


Часть 2. Кодинг

Использование фишек языка не по назначению

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

Избыточный force unwrapping

var lastPage: Int? = nil
lastPage! += 1 // Произойдёт ошибка: операция на nil не может быть выполнена

Swift — типобезопасный язык. В нём достаточно красивых способов написать аккуратный код без использования force unwrap. В сообществе iOS-разработчиков принято писать безопасный код и избегать force-операций, ведь они могут привести к сбою приложения, и оно будет вылетать. Пока вы изучаете разработку под iOS, будьте бдительны в следующих ситуациях:

  • Когда Xcode предлагает «исправить» код на операции с восклицательным знаком: это и есть force-операции, их стоит избегать и переписывать на безопасные конструкции.

  • Присмотритесь к различным туториалам — там часто встречается код с force unwrap, но в учебных материалах force unwrap удобен именно для упрощения объяснения концепции и быстрого старта.

Вы можете отмахнуться от этого совета: «Ничего страшного не произойдёт в моём пет-проекте!» Да, не произойдёт. Относитесь к этому не только как совету писать безопасный код, но ещё и как к формированию best practice. В тестовых заданиях и на coding-интервью работодатели особенно пристально следят за форсами и могут уже на этом этапе забраковать вашу кандидатуру. Лучше сразу привыкать писать код так, как принято в сообществе.

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

var lastPage: Int? // Заведём некоторую опциональную переменную 
// Проверим её на nil и в тернарном операторе 
// возьмём ноль, если значение nil, 
// или прибавим единицу, если значение существует
let nextPage = lastPage == nil ? 0 : lastPage! + 1

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

  1. Optional Binding — самый используемый способ, когда мы распаковываем элемент, тем самым сразу проверяя на опциональность:

// Объявим опциональную переменную. Если не присвоить в неё значение, то по умолчанию будет присвоен nil
var lastPage: Int? 
// 1. Можно распаковать в новую константу и безопасно использовать константу внутри блока if
if let unwrappedLastPage = lastPage {
  // используйте unwrappedLastPage здесь
}

// 2. Часто используют то же имя, создавая неопциональную копию значения, если значение имеется :)
if let lastPage = lastPage {
  // несколько удобнее, так как не добавляет лишний мусор в нейминг (unwrapped)
}

// 3. Можно неявно определить с таким же именем, доступно для Swift 5.7
if let lastPage {
  // Этот вариант технически аналогичен предыдущему, но заметно короче!
}

Изначальный пример мы можем написать так:

var lastPage: Int?
let nextPage: Int 
if let lastPage {
   nextPage = lastPage + 1 // Избавились от force unwrap
} else {
   nextPage = 0 // использовали начальное значение
}
  1. Nil Coalescing Operator — позволяет взять дефолтное значение в случае опциональности:

let value = optionalValue ?? defaultValue

Снова попробуем переписать наш изначальный пример:

var lastPage: Int? 
// Возьмём значение, если оно есть, и прибавим единицу, чтобы получить следующую страницу
// Возьмём -1 в случае nil, прибавим единицу — получим индекс первой страницы, 0
let nextPage = (lastPage ?? -1) + 1
  1. Guard Statement — позволяет прервать выполнение блока, если значение опционально.

guard let lastPage = lastPage else {
  return
}

// используйте lastPage здесь
guard let currentPage else { // Короткий вариант в Swift 5.7, аналогичный if let
  return
}
// используйте currentPage здесь

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

Force unwrap можно использовать в редких ситуациях, если вы на 100% уверены, что переменная никогда не будет зависеть от внешнего окружения и не превратится в optional.

Например, константа с указанием адреса:

let appleURL = URL(string: "<https://www.apple.com>")!

Здесь мы, во-первых, уверены, что описали верную ссылку на сайт, во-вторых, это константа, и её значение статично независимо от состояния приложения.

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

var website = "https://www.apple.com"
var url: URL {
  URL(string: website)!
}
print(url) // "https://www.apple.com" – Ух! Пока работает (потому что ссылка верная)
website = "one more thing..." // Перезапишем значение переменной другой многозначительной строкой
print(url) // Ошибка! Форс анврапнули внутри блока var url, всё взорвалось!

guard

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

Однако новички любят условный оператор if и используют его очень активно:

func processName(name: String?) {
  if let name = name {
    // делаем что-то с именем
    print("Привет, \(name)!")
  }
}

Получился вполне безопасный вариант, ведь аргумент name распакован в константу с помощью if let, однако вложенность такого кода равна двум: один за функцию, два за блок if. Если нам понадобится проверить ещё и длину имени, чтобы вывести фразу, то вложенность сразу возрастёт до трёх:

func processName(name: String?) {
  if let name = name {
    // делаем что-то с именем
    print("Привет, \(name)!")
    if name.count < 3 {
       print("\(name), какое короткое у тебя имя!")
	}
  }
}

Разработчики не любят глубокую вложенность, потому что такой код сложнее тестировать, читать и в нём легче ошибиться. Поэтому всячески стараются распрямлять код — например, с помощью guard:

func processName(name: String?) {
  guard let name = name else {
    return
  }
  // делаем что-то с именем
  print("Привет, \\(name)!")
  
  guard name.count < 3 else {
    return 
  }
  print("\\(name), какое короткое у тебя имя!")
}

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

Итак: используйте guard для распрямления кода и построения позитивного сценария работы функции.

Иногда guard из лаконичного оператора превращается в раздутый if из первого примера:

func handle(message: Message?, error: String?) {
  guard error == nil else {
    // сообщаем об ошибке и выходим!
    print("Упс! \\(error!)!")
    removeHistory()
    fastLogout()
    return
  }
  
  guard let message else { return }
  // делаем что-то ещё
}

Мы уже посмотрели, как можно избегать force unwrap, и в данном случае с error! стоит поступить так же: аккуратно распаковать. Обратите внимание на раздутый блок guard else. Блок else в этом случае не предполагает выполнения сложных действий. Если вы обнаружили в блоке guard else большой код, то, возможно, ваша функция делает слишком много. Поэтому код, предложенный выше, можно разбить на несколько дополнительных функций:

func handle(message: Message?, error: String?) {
  if let error = error { // Распаковали error, чтобы избавиться от force unwrap
    processError(error)
  } else if let message = message {
    processMessage(message)
  }
}

func processError(_ error: String) {
  print("Упс! \(error)!")
  removeHistory()
  fastLogout()
}

func processMessage(_ message: Message) {
  // Обрабатываем сообщение
}

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

Возможны ситуации с другими вариантами: message и error — nil, тогда ничего не выполнится. Ещё один вариант: когда есть и ошибка, и сообщение — непонятно, как верно обработать эту ситуацию. Если есть ошибка, сообщение будет проигнорировано. В данном случае в Swift нам доступен специальный generic-тип Result<Value, Error>, который и поможет исключить эти случаи:

guard — очень удобный оператор ветвления. Мои рекомендации по нему:

  1. Используйте guard для проверки значений опциональных переменных на nil.

  2. Помещайте guard как можно ближе к началу функции или блока.

  3. Используйте guard, чтобы отсечь ситуации, когда условия не выполняются. Так вы предотвратите глубокую вложенность кода.

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

  5. Следите за размером блока else: если в нём получилось много кода, то, возможно, пора рефакторить.

  6. Используйте guard только для проверки условий, которые могут вызвать проблемы или ошибки в дальнейшем коде. Помните о подходе с позитивным сценарием функции.

switch

Этот оператор можно использовать для проверки значений различных типов данных, таких как числа, строки, перечисления (enum), булевы значения и другие. switch включает в себя блоки case, каждый из которых содержит определённое значение, которое нужно проверить и каким-то образом обработать. Давайте посмотрим на следующий пример, который я часто встречаю у студентов.

switch isAvailable {
case true: 
   print("Достпуно!")
case false: 
   print("Ограничено")
}

Новичок использует switch для описания поведения для булева значения. В данном случае конструкция простая и легко читается. Однако лучше использовать вариант с if else, потому что он чаще употребляется и гораздо легче кастомизируется, если понадобится добавить дополнительные проверки:

if isAvailable {
  print("Достпуно!")
}
else {
  print("Ограничено")
}

Рассмотрим ещё пару особенностей использования switch. Допустим, есть switch, обрабатывающий стороны света:

switch direction {
case .west: goToWest() 
case .east: goToEast()
case .north: break
default: break
}

В блоке case .north: break приложение не делает ничего, так как используется break, что может быть неочевидным для других разработчиков, которые будут читать этот код. Вы можете добавить причину в виде комментария. Также можно обратить внимание, что в данном случае мощность switch использована не полностью: только два направления обработаны, — и вы могли бы упростить код, использовав default и для north тоже:

switch direction {
case .west: goToWest() 
case .east: goToEast()
default: 
  // Ничего не делаем
  break
}

Использование варианта default хорошо для случаев, когда мы описываем фиксированное количество кейсов и можем гарантировать, что не собираемся добавлять новые элементы в enum’ы. Однако enum’ы в разработке используются очень часто, и возникает потребность их менять и дополнять новыми кейсами. Что произойдёт с этим кодом, если вы добавите ещё четыре промежуточные стороны света? default по умолчанию сделает всё за вас: пропустит их обработку. Это может быть неудобно, если подобных свитчей в приложении много, и было бы здорово сразу поправить код везде, где это требуется. Если мы откажемся от default, то при добавлении сценария компилятор выдаст ошибку и попросит описать все сценарии. Знание этой особенности позволяет сэкономить время на отладке. И исходя из этой мысли стоит переписать свитч с полным перебором вариантов:

switch direction {
case .west: goToWest() 
case .east: goToEast()
case .north, .south: 
  // Если направление на север или на юг, то ничего не делаем
  break
}

На что стоит обратить внимание при работе со switch:

  • Использование оператора не должно быть избыточно, и если его легко можно переписать с помощью if, то лучше отказаться от switch.

  • Перечислять все возможные случаи, чтобы убедиться, что ничего не упущено, и иметь возможность заметить ошибку при дополнении новыми случаями.

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

Ошибки при реализации паттерна delegate

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

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

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

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

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

  1. Добавить новый сервис, который будет получать новые данные.

  2. Вью-контроллер сохраняет ссылку на сервис, чтобы просить его загрузить новые данные.

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

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

final сlass MyViewController: UIViewController {
  var service: LoadItemService?
  
  func viewDidLoad() {
    super.viewDidLoad()
    
    let service = LoadItemServiceImplementation()
    service.delegate = self // 1. Создаём связь с делегатом, в качестве делегата — контроллер
    self.service = service  // 2. Сохраняем ссылку на сервис
  }
  
  func viewDidAppear() {
	 super.viewDidAppear() 
     service.loadElement()
   }
}

protocol LoadElementDelegate {
  /// Действие по обработке полученного элемента
  func handleLoaded(_ item: VeryImportantItem)
}

extension MyViewController: LoadElementDelegate {
  func handleLoaded(_ item: VeryImportantItem) {
    // Делаем что-то очень важное с элементом
  }
}

final class LoadItemServiceImplementation: LoadItemService {
  var delegate: LoadElementDelegate?
  
  func loadElement() {
    let item = VeryImportantItem()
    delegate.handleLoaded(item) // Делегируем обработку полученного элемента
  }
}

Тут новичков поджидает такая ошибка: создание сильной связи между двумя ссылочными объектами.

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

В данном случае retain cycle возникает из-за установки сильной ссылки между вью-контроллером и объектом сервиса, а также сохранения реализации сервиса в переменной service в классе вью-контроллера.

Когда мы устанавливаем delegate в self, создаём сильную ссылку между вью-контроллером и сервисом. А когда сохраняем реализацию сервиса в переменную service в классе вью-контроллера, создаём вторую сильную ссылку, и в результате образуется ссылочный цикл.

Для освобождения памяти в iOS используется система подсчёта сильных ссылок на каждый объект, называемая ARC (Automatic Reference Counting). Она определяет, нужно ли ещё держать объект в памяти. Когда счётчик ссылок достигнет нуля, объект автоматически освободится из памяти.

Чтобы исправить ошибку со ссылочным циклом в данном случае, необходимо ослабить одну из сильных ссылок, используя ключевое слово weak. В частности, мы можем сделать делегат слабой ссылкой, используя weak var delegate: LoadElementDelegate?. Это ослабит ссылку между вью-контроллером и сервисом и предотвратит возникновение сильного ссылочного цикла.

 weak var delegate: LoadElementDelegate? 

Важный момент: ссылка на делегат всегда слабая, а обратная ссылка — сильная.

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

Рассмотрим другую ошибку с проектированием связей между элементами. Предположим, новая задача состоит из трёх экранов:

  • Вспомогательный экран SplashViewController — который при необходимости предложит пользователю пройти авторизацию, иначе покажет главный экран.

  • За авторизацию будет отвечать AuthViewController, о результатах он будет сообщать с помощью делегата на вызвавший его SplashViewController.

  • В качестве главного экрана — профиль пользователя ProfileViewController. С которого пользователь может разлогиниться.

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

final class SplashViewController: UIViewController {
  func showAuthVC() {
    let authVC = AuthViewController() 
    authVC.delegate = self 
    present(authVC, animated: true)
  }
   
  func showProfile() {
    let profileVC = ProfileViewController()
    present(profileVC, animated: true)
  }
  
  func loginSuccessful() {
    showProfile()
  }
}

final class AuthViewController: UIViewController {
  var delegate: AuthViewControllerDeleate?
  
  func handleSuccessResult() {
    delegate?.loginSuccessful()
  }
}

final class ProfileViewController: UIViewController {
  func logout() {
    let authVC = AuthViewController()
    // для упрощения примера пусть эта функция устанавливает
    // новый вью-контроллер в иерархии в качестве корневого
    replaceCurrentRootVC(with: authVC)
  }
}

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

weak var delegate: AuthViewControllerDeleate?

Может показаться, что никто не держит AuthViewController, но это не так. Контроллер, размещённый в иерархии экранов, уже сильно удерживается. Поэтому нам нужно ослаблять ссылку тоже.

Интереснее следующее: в сценарии logout повторно создаётся AuthViewController и размещается в представлении. Здесь новичок забыл установить ему delegate. Кто тогда будет обрабатывать результат авторизации? В этом случае пользователь попадёт в тупик, потому что никто не обработает действия от AuthViewController и некому определять, куда навигировать дальше. Здесь мы сталкиваемся с неправильным проектированием связей между элементами.

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

Эту ошибку я связываю с неловкостью в обращении с этим паттерном. Если сразу вспомнить, что AuthViewController не будет работать без компаньона-делегата, то такой ошибки бы не возникло.

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

Будьте внимательны:

  • избегайте создания сильной связи между объектами,

  • не забывайте устанавливать делегат и проверяйте осмысленность созданных связей.


Мы разобрали ошибки, которые я встречаю у новичков чаще всего. Если у вас появились вопросы по поводу ошибок, которые мы разобрали, или если есть другие решения, я буду рада обсудить их в комментариях и помочь вам :)


Что ещё поможет разобраться в iOS-разработке:

Комментарии (5)


  1. varton86
    00.00.0000 00:00

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


    1. bshkrva Автор
      00.00.0000 00:00
      +1

      Спасибо, за интерес к статье! Действительно стоило упомянуть про передачу данных между контроллерами, да и просто про индикаторы в сторибордах. Материал в этом смысле – взгляд со стороны код-ревьювер , на этапе ревью такие ошибки студент уже победил (иногда не без помощи Наставников курса).

      Быть может, если материал будет полезным, мы с командой соберем еще кейсов :)


  1. NineNineOne
    00.00.0000 00:00

    В 2023 году учить студентов верстке в сторибордах и на UIKit - это очень любопытное решение. Очень любопытное.


    1. bshkrva Автор
      00.00.0000 00:00

      Привет! Спасибо за комментарий.

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

      Также стоит учесть, что переход компаний на новые инструменты и подходы занимает время, и не все команды используют те подходы, которые вам кажутся верными. Умение работать со сторибордами и XIB'ами по-прежнему актуально, так как многие все еще используют их для разработки или поддержки существующих проектов.

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

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


    1. Gargo
      00.00.0000 00:00

      А что у компаний не бывает старых проектов на UIKit? Или может SwiftUI на 100% может обходиться без UIKit?

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