DRY: у каждого знания есть одно авторитетное место. KISS: выбирайте самое простое решение, которое решает сегодняшнюю задачу. YAGNI: не стройте под требования, которых пока нет. DRY вредит, когда вы объединяете код, который только выглядит одинаково, а меняется по разным причинам.
DRY касается знания, а не текста: базовый URL API, правила пароля, формат даты. Когда что-то из этого меняется, правка должна случиться ровно в одном месте. Похожие строки с разными владельцами нарушением не считаются.
Когда DRY вредит: если объединить валидацию регистрации и редактирования профиля, потому что сегодня они совпадают, два экрана окажутся связаны, и первое же различие превратится во флаг. Выносите общее только на третьем повторении; в тестах читаемая повторяющаяся подготовка лучше хитрого общего хелпера.
KISS в Swift: структура и функция вместо иерархии дженерик-протоколов; @State и .task вместо view model для экрана с двумя полями.
YAGNI: никакой системы плагинов и никакого протокола «для гибкости», пока не понадобится вторая реализация или тест. Это не повод пропускать то, что дёшево сейчас и дорого потом: accessibility-метки, локализуемые строки, поле версии в сохраняемых данных.
iOSMiddle
ООП, SOLID и паттерны проектирования
ООП и SOLID в Swift, DRY, KISS и YAGNI, связность и зацепление, внедрение зависимостей, паттерны GoF, delegate, target-action, observer и coordinator, антипаттерны
Закрыто вопросов
0 / 18
- 0Повторить
- 2Часто спрашивают
Вопросы
Связность (cohesion) показывает, насколько части одного типа или модуля принадлежат друг другу; связанность (coupling) показывает, насколько один тип зависит от деталей другого. Стремитесь к высокой связности и низкой связанности: тогда изменение остаётся в одном месте и не расходится по остальным.
У SRP есть измеримая сторона, и это связность: тип с одной причиной для изменения связен. У view controller, который форматирует цены, ходит в API и решает навигацию, три причины, а его методы трогают непересекающиеся наборы свойств: обычный признак того, что внутри на самом деле три типа. Utils и AppManager выдают низкую связность уже названием.
Виды связанности в iOS-коде: на конкретный тип (NetworkManager.shared внутри метода), на внутренности чужого объекта (navigationController?.viewControllers.first as? HomeViewController), на порядок вызовов (configure() до start()) и скрытая: через имя нотификации или ключ UserDefaults, набранный вручную с обеих сторон.
Как её ослабить: зависеть от маленького протокола, которым владеет вызывающая сторона, получать данные через init, а не дотягиваться до них, сообщать о событиях наверх через замыкание или делегат.
Быстрая проверка: сколько файлов затрагивает типичное изменение и какую часть приложения нужно собрать, чтобы протестировать один тип.
static let shared плюс private init(). Swift создаёт статическое свойство лениво и ровно один раз, даже если до него одновременно добираются несколько потоков. Проблема в дизайне: синглтон по сути глобальное состояние, до которого может дотянуться любая строка, поэтому зависимости прячутся, а тесты влияют друг на друга.
Безопасное создание, но не использование: static let проходит через swift_once, поэтому второго экземпляра не будет, но состоянию внутри всё равно нужен актор, @MainActor или лок. Именно поэтому Swift 6 отвергает static let, который хранит не-Sendable класс.
Строгий синглтон или общий экземпляр: UIApplication.shared может существовать только в одном экземпляре. URLSession.shared, UserDefaults.standard, FileManager.default и NotificationCenter.default являются общими экземплярами по умолчанию: свой можно создать, и так поступают тесты.
Скрытые зависимости и протекающие тесты: SessionStore.shared внутри метода не видно ни в одной сигнатуре, а его состояние переживает переход от одного теста к другому, поэтому параллельные прогоны Swift Testing видят чужие записи.
Когда это нормально: для чего-то действительно общего на всё приложение и почти без изменяемого состояния, например логгера или конфигурации приложения. Но и тогда передавайте его через init со значением по умолчанию .shared, чтобы вызывающий код мог его подменить.
Объявите протокол событий, ограниченный классами, дайте делегирующему объекту weak var delegate и вызывайте его в нужные моменты. Соглашения Cocoa делают один делегат переиспользуемым и выразительным: отправитель идёт первым, методы should спрашивают разрешения, will и did сообщают до и после.
Отправитель первым: tableView(_:didSelectRowAt:) передаёт таблицу, поэтому один контроллер может быть делегатом двух таблиц, различать их и читать состояние отправителя.
should, will, did: textFieldShouldBeginEditing(_:) возвращает Bool, и при false поле так и не начинает редактирование; scrollViewWillBeginDragging(_:) и scrollViewDidScroll(_:) только сообщают. Возвращаемое значение даёт то, чего нет у нотификации: делегат участвует в решении.
**weak и AnyObject**: владелец уже держит делегирующий объект (контроллер держит свою view), поэтому сильная обратная ссылка создала бы retain cycle, а weak требует протокола, ограниченного классами.
Делегат или KVO: делегирование работает один к одному, и объект сам решает, о чём сообщать; KVO позволяет любому числу наблюдателей следить за @objc dynamic свойством так, что объект о них не знает.
Принцип подстановки Лисков: код, работающий с FeedLoader, полагается на его обещание вызвать completion ровно один раз, а CachedFeedLoader вызывает его дважды. Checked continuation в feed(from:) после этого роняет приложение с SWIFT TASK CONTINUATION MISUSE: feed(from:) tried to resume its continuation more than once.
Чего требует Лисков: конформер или наследник обязан сохранять поведение, на которое полагаются вызывающие, а не только сигнатуры. Типы проверяет компилятор; остальную часть контракта (сколько колбэков, на каком потоке, какие ошибки, блокирует ли вызов) проверяют только вызывающие, во время выполнения.
Каждый вызывающий ломается по-своему: DispatchGroup, который вызывает leave() на каждый completion, падает на несбалансированном leave; экран добавляет посты дважды или мигает; событие аналитики уходит два раза. А ведь замена сетевого загрузчика на кэширующий должна была пройти незаметно.
Исправление: оставьте FeedLoader одноразовым. Если кэш служит запасным вариантом, загрузчик отвечает один раз: из сети или, если она не ответила, из кэша. Если экрану нужно «сначала кэш, потом свежие данные», речь уже о другом контракте со своим типом, например таким, который возвращает AsyncThrowingStream и может выдать значение дважды.
Как поймать: один контрактный тест, который прогоняется для каждого конформера: вызвать load один раз и проверить, что completion пришёл ровно один раз.
Ни один клиент не должен зависеть от методов, которыми не пользуется. В Swift это означает маленькие ролевые протоколы, названные по тому, что нужно одному вызывающему, и соединённые через &, если ему нужны две роли, вместо одного UserService, который приходится тащить за собой каждому экрану и каждому тестовому двойнику.
Во что обходится толстый протокол: каждый тестовый двойник реализует все методы, большинство через fatalError("unused"); изменение одного требования задевает всех конформеров; а init у view model больше не говорит, чем экран пользуется на самом деле.
Делите по клиентам, а не по методам: шапка профиля загружает пользователя и выгружает аватар, поэтому просит ровно эти две роли. Протокол на каждый метод будет ошибкой с другой стороны.
Один класс, много ролей: LiveUserService соответствует им всем, поэтому разделение не требует лишней реализации. typealias ProfileServices = CurrentUserLoading & AvatarUploading даёт имя повторяющейся комбинации.
Реализации по умолчанию, которые прячут проблему: расширение с пустым телом block(_:) позволяет конформеру заявить возможность, которой у него нет, и вызывающие получают молчаливый no-op. Реализация по умолчанию должна быть настоящим общим поведением, а не способом пропустить требование.
В SDK: Codable состоит из Encodable & Decodable, поэтому телу запроса нужен только Encodable; у таблиц отдельно data source, делегат и префетчинг; обобщённые алгоритмы просят Sequence, а не Array.
Внедрение зависимостей (dependency injection) описывает технику: объект получает свои зависимости, а не создаёт их сам. Инверсия зависимостей (dependency inversion) задаёт правило дизайна: и высокоуровневый, и низкоуровневый код зависят от абстракции, которой владеет высокоуровневая сторона. Можно внедрить конкретный клиент и не получить никакой инверсии.
Внедрение без инверсии: init(client: URLSessionAPIClient) внедряет зависимость, но модуль Feed по-прежнему не собирается без Networking, а тесту нужен этот конкретный клиент.
Инверсия без внедрения: service locator, который находит FeedLoading во время выполнения, инвертирует зависимость, но прячет её из сигнатуры.
Кому принадлежит протокол: FeedLoading живёт в модуле Feed и говорит на языке ленты (latestPosts()), а не HTTP. Networking никогда не импортирует Feed; маленький адаптер в таргете приложения, в composition root, связывает одно с другим. Стрелка, которая шла от Feed к Networking, теперь идёт от адаптера к обоим.
Что это даёт: Feed собирается и тестируется без Networking, а переход с REST на GraphQL меняет только адаптер.
Запах: протокол, названный по своей реализации (URLSessionProtocol, NetworkManagerProtocol), работает как тестовый шов, которым владеет низкоуровневая сторона. Полезно, но инверсией это не является.
Фабричный метод (factory method): один переопределяемый метод, который создаёт объект, так что конкретный тип выбирает наследник или конформер. Абстрактная фабрика (abstract factory): объект с несколькими такими методами, которые строят семейство связанных объектов и подменяются целиком: например, настоящие экраны на заглушки.
Фабричный метод в UIKit: UIView создаёт свой слой, спрашивая layerClass, поэтому наследник получает CAGradientLayer или AVPlayerLayer, а UIView не знает ни того, ни другого. loadView() играет ту же роль для корневой view контроллера.
Простые фабрики: то, что в Swift-коде обычно зовут фабрикой, чаще всего оказывается static func make… или инициализатором, который сам выбирает тип. Это не фабричный метод из GoF, но цель та же: вызывающий никогда не называет конкретный тип.
Абстрактная фабрика на практике: координатор получает фабрику CheckoutScreens, которая держит зависимости и знает инициализатор каждого экрана, поэтому координатор решает только порядок. Тесты и превью передают фабрику, которая возвращает контроллеры-заглушки; white-label сборка передаёт другое семейство.
Часто хватает замыкания: список, который получает makeDetail: (Item) -> UIViewController, имеет фабрику одного продукта вообще без протокола.
Когда не нужно: протокол фабрики, который оборачивает единственный вызов init, добавляет тип и ничего не даёт.
Редко в классическом виде: аргументы по умолчанию и поэлементные (memberwise) инициализаторы уже решают проблему «телескопических» конструкторов, ради которой в Java существует билдер. В Swift остаются конфигурационное значение, которое вы заполняете и передаёте, fluent-цепочка, возвращающая изменённые копии, и result builders для DSL.
Сначала значения по умолчанию: структура со значениями по умолчанию даёт Toast(message:duration:) и любую другую комбинацию без лишнего кода, а компилятор по-прежнему требует обязательные поля.
Конфигурационные значения: UIButton.Configuration, UIListContentConfiguration, URLComponents и DateComponents заполняются шаг за шагом и передаются объекту, который их использует: это работа билдера (сначала собрать, потом один раз создать), выполненная обычной структурой.
Fluent-копии: методы, которые возвращают изменённую копию структуры, делают цепочки .method("POST").header(...) читаемыми и безопасными для совместного использования. Модификаторы SwiftUI устроены по той же идее.
Когда настоящий билдер окупается: сборка растянута во времени или по нескольким местам (multipart-тело, запрос из фильтров), или инвариант охватывает несколько полей и должен проверяться один раз, в build() throws, который отклоняет дату окончания раньше даты начала.
Result builders: @ViewBuilder и RegexBuilder превращают блок инструкций в одно значение; их стоит писать для DSL, который должен читаться как документ, а не для структуры с пятью полями.
Адаптер подгоняет один существующий тип под интерфейс, которого ждёт ваш код; фасад ставит один простой интерфейс перед целой подсистемой. Обёртка над SDK обычно сочетает оба: фасад с теми немногими вызовами, которые нужны приложению, и код адаптера за ним, который преобразует типы, колбэки и ошибки вендора.
Адаптер в Swift часто занимает одну строку: когда сигнатуры уже совпадают, extension UserDefaults: SettingsStore {} и есть весь адаптер. Когда не совпадают, обёртка преобразует: делегат или поток в async, ошибки вендора в ваши, CLLocation в координату, которая нужна фиче.
Фасад: CameraController с методами start(), capturePhoto() async throws -> Data и stop() прячет AVCaptureSession, входное устройство, AVCapturePhotoOutput, проверку разрешений и очередь сессии. Вызывающие сознательно отказываются от гибкости.
Зачем вообще оборачивать SDK: типы вендора перестают просачиваться в фичи, поэтому новый вендор или новая мажорная версия затрагивают один файл; тесты передают фейк, соответствующий вашему протоколу; а правила работы с потоками живут в одном месте, что важно, когда SDK вызывает колбэки на произвольных потоках.
Ловушка: обёртка, которая один в один повторяет каждый метод вендора, оставляя его типы в своих сигнатурах. Она добавляет файл и ничего не защищает; форму интерфейса должно задавать то, как им пользуется приложение.
Декоратор соответствует тому же протоколу, что и оборачиваемый объект, и добавляет поведение вокруг каждого вызова, поэтому кэширование, логирование или повторы складываются слоями для отдельного экземпляра. У прокси та же форма, но другая задача: он управляет доступом к настоящему объекту, создавая его лениво, охраняя его или пересылая вызовы через границу.
В сравнении с расширением: расширение добавляет новые методы всем экземплярам типа на этапе компиляции. Оно не может изменить то, что делает image(for:), не может применяться к одному экземпляру, но не к другому, и не может хранить состояние вроде кэша.
В сравнении с наследником: наследник фиксирует одну комбинацию на этапе компиляции, поэтому кэширование плюс логирование плюс повторы превращаются в класс на каждую комбинацию, а структуры и final-классы наследовать вообще нельзя. Декораторы комбинируются во время выполнения, в composition root.
Порядок задаёт поведение: логирующий декоратор снаружи кэша пишет каждый запрос, внутри него только промахи кэша.
Прокси в iOS: слабый прокси в роли target у CADisplayLink или Timer, чтобы link больше не держал view живой; NSXPCConnection.remoteObjectProxy, который пересылает вызовы в другой процесс; ленивый прокси, который создаёт дорогой SDK или базу данных при первом обращении.
SwiftUI: каждый модификатор оборачивает view в ModifiedContent, то есть работает как декоратор, применённый по значению.
Храните наблюдателей в коллекции слабых ссылок, например NSHashTable.weakObjects(), и при изменении проходите по снимку. Субъект никогда не держит экран живым, а освобождённый наблюдатель просто выпадает из коллекции и больше не получает вызовов.
Почему weak: субъект (плеер, сессия, менеджер загрузок) обычно живёт дольше экранов, которые за ним наблюдают. Массив сильных ссылок держит живым каждый экран, который когда-либо подписался, и продолжает вызывать view, которые никто не видит.
Оповещайте по снимку: наблюдатель может отписаться или подписать другого прямо в своём колбэке; allObjects сначала копирует таблицу, поэтому цикл никогда не идёт по коллекции, которая меняется у него под ногами.
Без права вето: порядок доставки в хэш-таблице не определён, а метод should с возвращаемым значением при многих слушателях теряет смысл. Решения остаются за делегатом один к одному.
Готовые альтернативы: PassthroughSubject или @Published из Combine (каждый подписчик держит свой AnyCancellable, так что время жизни задаёт подписка), Observation для состояния, которое читает UI, NotificationCenter для событий уровня приложения. Один AsyncStream здесь не подойдёт: два потребителя делят его элементы между собой.
Запрос идёт по цепочке обработчиков, пока один из них его не обработает, и отправитель так и не узнаёт, какой именно. Responder chain в UIKit как раз такой случай: ячейка отправляет запрос наверх, и его обрабатывает первая view, контроллер или объект приложения, который на это способен, без всякой проводки делегатов.
Сама цепочка: next у view указывает на её superview или на её view controller, если это корневая view контроллера. next у контроллера указывает на окно, если его view корневая в окне, на presenting-контроллер, если его показали модально, а иначе на superview его view. От окна цепочка идёт к его window scene, затем к UIApplication и делегату приложения.
Встроенные применения: необработанные касания и нажатия, шорткаты UIKeyCommand, действия меню редактирования вроде copy(_:) (кто ответит, решает canPerformAction(_:withSender:)) и любое действие, отправленное с nil-target: оно начинает с first responder.
Типизированный обход в Swift: приведение каждого next к протоколу, как в коде, не требует @objc-селектора и даёт проверяемую компилятором сигнатуру метода.
Компромисс: связь невидима. Ничто не гарантирует, что обработчик найдётся, поэтому ячейка, переиспользованная там, где ни один предок не обрабатывает действие, молча ничего не делает; в debug-сборках ставьте assert, если обработчик не найден. Для флоу, которые обязаны случиться, явное замыкание или делегат понятнее.
Собственные цепочки подходят, когда применимы несколько обработчиков и их порядок сам задаёт политику, например обработчики диплинков, которых спрашивают по очереди, пока один не примет URL.
Стратегия прячет каждый вариант алгоритма за одним интерфейсом, а вызывающий выбирает или меняет вариант во время выполнения, поэтому код, который им пользуется, никогда не делает switch по вариантам. В Swift стратегия оформляется как маленький протокол или просто замыкание, если вся она сводится к одной функции.
В SDK: JSONDecoder.dateDecodingStrategy и keyDecodingStrategy (с замыканием .custom), sorted(by:), section provider у compositional layout, ButtonStyle, LabelStyle и FormatStyle в SwiftUI. Компонент остаётся тем же; стратегия решает, как он декодирует, сортирует, раскладывает или выглядит.
Протокол или замыкание: замыкание для одной функции без состояния, вроде (Product, Product) -> Bool; протокол, когда у стратегии несколько методов или настройки, нужно имя в логах или она должна быть Equatable для тестов.
Strategy и State: одна и та же структура, но разный двигатель. Стратегию выбирает клиент, и за время жизни объекта она меняется редко; состояние заменяет сам объект по мере прихода событий, и каждое состояние знает, какие состояния могут идти за ним.
**Когда switch лучше**: закрытый набор из двух-трёх вариантов, которыми вы владеете. Enum с методом проще, а при добавлении нового case компилятор укажет на каждый switch, который должен его обработать.
stopTapped() не завершает буферизацию, а bufferingDidFinish() запускает воспроизведение, не проверяя, чего пользователь хочет сейчас. Когда колбэк движка приходит после Stop, он ставит isPlaying = true и вызывает engine.start(): запоздавшее событие перебивает последнее действие пользователя.
Почему так происходит: колбэк приходит как асинхронное событие, а код обращается с ним как с командой. Два независимых Bool описывают четыре комбинации, и тип ничем не исключает невозможные, например буферизацию и воспроизведение одновременно или буферизацию после Stop.
Как работает исправление: одно перечисление State и единственный send(_:), который делает switch по (state, event). bufferingFinished принимается только в .buffering; в .idle событие уходит в default и игнорируется, поэтому движок не запускается.
Передавайте в событиях идентичность: .bufferingFinished(url) должен совпадать с URL, который буферизуется. Иначе Stop, затем Play на другом видео позволят запоздавшему колбэку первого видео запустить второе раньше, чем оно готово.
Enum или объекты паттерна State: в Swift по умолчанию выбирают enum: исчерпывающий switch, семантика значений, Equatable-состояние для проверок в тестах. Класс на каждое состояние (BufferingState, PlayingState) окупается, только когда у каждого состояния много собственного поведения.
Как поймать: тест, который отправляет события во враждебном порядке (play, stop, буферизация завершена) и проверяет, что состояние равно .idle, а spy-движок так и не получил start().
Команда превращает запрос в объект, который можно сохранить, поставить в очередь, записать в лог или откатить позже. UndoManager построен на ней: каждое изменение регистрирует замыкание, которое его отменяет, а стеки undo и redo состоят просто из сохранённых команд.
Регистрируйте обратное действие в момент изменения: registerUndo(withTarget:handler:) сохраняет замыкание, а setActionName(_:) даёт пункту меню заголовок «Undo Add Shape».
Redo появляется, если обратное действие тоже регистрируется: всё, что зарегистрировано во время отмены, попадает в стек redo. Пишите remove так, чтобы он регистрировал add, а add так, чтобы он регистрировал remove; обработчик, который меняет состояние напрямую в обход этих методов, оставляет Redo серым.
Называйте шаг только вне отмены: вызванный во время отмены, remove(_:) переименовал бы шаг redo в «Remove Shape»; если пропускать setActionName, пока идёт isUndoing, останется «Redo Add Shape».
Группировка: при groupsByEvent (по умолчанию) всё, что зарегистрировано за один проход run loop, становится одним шагом отмены; beginUndoGrouping() и endUndoGrouping() позволяют отменить изменение из нескольких шагов как одно.
Откуда он берётся: у каждого UIResponder есть undoManager (отмена встряхиванием, жесты тремя пальцами), SwiftUI отдаёт его через @Environment(\.undoManager), а текстовые view сами регистрируют свои правки.
Другие команды в SDK: UIAction представляет действие как значение, а Operation оформляет работу объектом в очереди.
Абстракция неправильна, когда вызывающим приходится передавать флаги или особые случаи, чтобы получить от неё своё поведение. Выходите так: подставьте её код в каждое место вызова, удалите то, чем это место не пользуется, и только потом выносите то, что действительно общее: дублирование дешевле неправильной абстракции.
Как это происходит
В первый день два куска кода выглядят одинаково, и их объединяют. Требования расходятся, и каждый новый вызывающий добавляет параметр вместо того, чтобы усомниться в объединении: isProfile, mode: .compact, замыкание «для кастомизации».
Признаки
Булевы параметры или параметры режима, которые выбирают поведение, и ветки, до которых доходит только один вызывающий.
Изменение для одного экрана ломает другой, а исправление бага требует нового флага, чтобы не задеть остальных.
Базовый класс, наследники которого переопределяют методы, чтобы ничего не делать, или дженерик ListViewController<Item, Cell>, настраиваемый десятью замыканиями.
Как выйти
Сначала зафиксируйте поведение: снапшот-тесты для каждого экрана, который пользуется компонентом.
Подставьте код: дайте каждому вызывающему свою копию, затем удалите из каждой копии все ветки, которые этот вызывающий никогда не проходит. Копии обычно оказываются гораздо меньше оригинала.
Снова выносите из копий только то, что совпадает и меняется по одной и той же причине; обычно это маленький листовой компонент вроде превью картинки или форматтера цены, а не вся строка.
Переводите по одному вызывающему на пул-реквест, чтобы каждое ревью оставалось читаемым.
Как не допустить
Выносите механику (примитивы вёрстки, форматирование), а не политику (что строка показывает на каком экране), и собирайте экран из маленьких view вместо настройки одной большой: компонент дизайн-системы с несколькими именованными вариантами лучше компонента с восемью флагами.
Первым делом я ищу god object, синглтоны как скрытые зависимости и глубокий BaseViewController. Каждый из них прячет зависимости или связывает несвязанные фичи, и из каждого есть постепенный выход без переписывания: переносите по одной ответственности за шов.
God object
Признак: AppManager, NetworkManager или Utils с сотнями методов, которые импортирует и правит каждая фича; конфликты слияния собираются именно там.
Выход: выпишите, какие фичи вызывают какие методы, перенесите каждую группу за ролевой протокол в модуль, который ею пользуется, а старый тип оставьте фасадом, который пересылает вызовы, пока не переедет последнее место вызова.
Синглтоны повсюду
Признак: .shared внутри тел методов, запуск, который работает только благодаря случайному порядку обращений, тесты, которые проходят по отдельности и падают вместе.
Выход: протокол поверх каждого синглтона, внедрённый с .shared в качестве аргумента по умолчанию; позже настоящие экземпляры из composition root и состояние, привязанное к сессии или флоу, а не к процессу.
BaseViewController
Признак: базовый класс, который начинался со спиннера, а теперь держит аналитику, работу с клавиатурой, алерты об ошибках и хук для одной фичи; каждый экран платит за всё это.
Выход: композиция: маленькие помощники (KeyboardAvoider, ErrorPresenter), дочерние view controller, расширения протоколов, ограниченные UIViewController, модификаторы view в SwiftUI. Вынесите одну ответственность, удалите её из базового класса, повторите.
Начинайте с того, что мешает работе прямо перед вами, с файла, который нужно поменять в этом спринте, а не с худшего файла в репозитории.