Привет, меня зовут Антон, я iOS-разработчик в компании Surf. Все программисты хотят писать красивый, масштабируемый и сопровождаемый код, и мобильщики – не исключение. Как в этом помогут принципы SOLID, и зачем они нужны?

Расскажем и покажем на примерах (куда без этого). Кейсы в статье относятся к iOS-разработке, но они будут полезны для всех разработчиков, независимо от стека.

Что такое SOLID?

SOLID — это акроним из первых букв пяти основных принципов проектирования в объектно-ориентированном программировании:

  • Single Responsibility (SRP) — принцип единственной ответственности;

  • Open-Closed (OCP) — принцип открытости-закрытости;

  • Liskov Substitution (LSP) — принцип подстановки Барбары Лисков;

  • Interface Segregation (ISP) — принцип разделения интерфейсов;

  • Dependency Inversion (DIP) — принцип инверсии зависимостей.

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

Гоша

Знакомьтесь, это Гоша*. Он начинающий iOS-разработчик. В рамках этой статьи мы поставим себя на место менторов: будем ревьюить код Гоши и помогать улучшить его с помощью знаний SOLID. 

*Все совпадения случайны
*Все совпадения случайны

Давайте разберёмся с каждым принципом.

SRP (Single Responsibility)

У каждого класса* должна быть только одна зона ответственности, полностью инкапсулированная в нём. То есть у класса должна быть только одна причина для изменения

*В нашей статье понятия класс, структура, тип, объект могут быть взаимозаменяемы и означают просто сущность в программном коде. Не стоит воспринимать их как ключевые слова языка программирования class, struct, object, Type.

Следование этому принципу обычно заключается в разбиении сложных объектов на простые со специализированной зоной ответственности. А также (про что часто забывают) в объединении однотипной функциональности, распределенной по коду, в один объект.

Нарушение SRP — создание God-object, больших классов с разнообразными обязанностями и обширным функционалом.

Признак нарушения SRP — в коде есть объекты, которые постоянно изменяются при работе над проектом — через них проходит множество осей изменений.

Преимущества:

  • уменьшает side-эффекты;

  • повышает читаемость и связность кода;

  • упрощает локализацию и декомпозицию задач;

  • увеличивает переиспользуемость кода;

  • уменьшает количество дублирующихся багов.

Кейс №1

В рамках разработки фичи авторизации Гоше поручили написать кастомное поле ввода в соответствии с макетами приложения. 

Помимо ввода текста необходимо валидировать введенные значения. Валидацию Гоша решил сделать прямо в структуре поля, проверил на вводе почты – всё работает.

struct CustomTextField: View {
    let title: String 
    @State private var text: String
    @FocusState private var isFocused: Bool
    
    var body: some View {
        TextField(title, text: $text)
            .textFieldStyle( … )
            .onChange (of: isFocused) { isFocused in
                guard !isFocused else { return }
                validate()
            }
    }
    
    private func validate() { … } // Валидайция имейла
}

Какие могут возникнуть проблемы? 

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

private func validate() {
    switch type {
    case.email:
        // Валидайция имейла
    case.phone:
        // Валидайция телефона
    case .inn:
        // Валидайция ИНН
    }
}

Как лучше поступить?

Стоит вынести логику валидации в отдельный объект-валидатор и передать его в инициализатор при создании поля. Либо указать тип поля и с помощью фабрики получить нужный валидатор. 

protocol Validator {
    var errorMessage: String { get }
    func validate(_ value: String) -> Bool
}

struct INNValidator: Validator { 
    let errorMessage: String

    func validate(_ value: String) -> Bool {
        value.count == 12
    }
}


struct CustomTextField: View {
    @State private var text: String
    @FocusState private var isFocused: Bool
    let title: String
    let validator: Validator

    var body: some View {
        TextField(title, text: $text) 
            .textFieldStyle()
            .onChange(of: isFocused) { isFocused in 
                guard !isFocused, 
                      validator.validate(text) else { return }
                // отобразить ошибку
        }
    }
}

Кейс №2

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

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

Гоша взял код тоста из авторизации и добавил в профиль, поменяв цвет и шрифт.

struct SignInView: View {
    @State private var toastText: String?

    var body: some View {
        ZStack(alignment: .bottom) {
            content()
            if let toastText {
                toastView(text: toastText)
            }
        }
    }
}

private extension SignInView {
    func content() -> some View { … }

    func toastView(text: String) -> some View {
        ZStack {
            RoundedRectangle(cornerRadius: 4)
                .foregroundColor(.blue)
            Text(text)
                .padding()
        }
    }

    func trySignIn() {
        // При ошибке валидации или авторизации
        // withAnimation { toastText = «Текст ошибки» }
        // Для скрытия тоста — toastText = nil спустя некоторое время
    }
}


struct ProfileView: View {
    @State private var toastText: String?

    var body: some View {
        ZStack(alignment: .bottom) {
            content()
            if let toastText {
                toastView(text: toastText)
            }
        }
    }
}

private extension ProfileView {
    func content() -> some View { … }

    func toastView(text: String) -> some View {
        ZStack {
            RoundedRectangle(cornerRadius: 4)
                .foregroundColor(.green)
            Text(text)
                .font(.system(size: 14))
                .padding()
        }
    }

    func showSignUpSuccess() {
        // При ошибке валидации или авторизации
        // toastText = «Текст ошибки»
    }
}

Какие могут возникнуть проблемы? 

Это тоже нарушение SRP. Части кода, которые отвечают за одну и ту же функциональность, разнесены по проекту. Из-за этого появляется большое количество полностью или частично дублирующегося кода. А если нужно внести изменения или пофиксить баги, придётся править все места использования (о чем можно не знать или забыть).

Как лучше поступить?

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

struct ToastAlertModifier: ViewModifier {
    @Binding private var toastText: String?
    @State private var showToast: Bool = false
    private let color: Color
    private let font: Font
    @State private var timer = Timer.publish(
                                      every: 5,
                                      on: .main, 
                                      in: .common
                                     ).autoconnect()
    
    func body(content: Content) -> some View {
        ZStack(alignment: .bottom) {
            content
            if let toastText {
                ZStack {
                    RoundedRectangle(cornerRadius: 4)
                        .foregroundColor(color)
                    Text(toastText)
                        .font(font)
                        .padding()
                }
            }
        }
        .onChange(of: toastText) {
            withAnimation {
                showToast = toastText != nil
                restartTimer()
            }
        }
        .onReceive(timer) { _ in
            showToast = false
        }
    }

    private func restartTimer() { … }
}

OCP (Open-Closed)

Программные сущности (классы, модули, функции и другие) должны быть открыты для расширения, но закрыты для изменения

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

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

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

Преимущества:

  • не появляются новые баги;

  • не требуется регрессионное тестирование при каждом изменении;

  • код легко изменять и масштабировать.

Кейс №3

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

Он решил использовать новый метод с возможностью передавать фильтры прямо под поиском по текстовому запросу.

class SearchViewModel {
    private var query = SearchQuery()

    // …
    // большой сложный класс
    // …

    func search(with queryText: String) {
        query.reset()
        query.setQueryText(queryText)
        // …
    }

    // Новый метод фильтрации
    func addFilters(_ filters: [Filter]) { … }
}

Какие могут возникнуть проблемы?

Такой подход нарушает OCP. При добавлении новой функциональности в работающий код может возникнуть множество багов. Более того,  это приведёт к разрастанию класса. 

Фильтрация может стать доступна не на всех экранах с возможностью поиска — придется добавлять флаги. Возможно, в дальнейшем понадобится добавить фильтрацию по тегам или вложенную фильтрацию (фильтрацию уже отфильтрованной выборки, например, внутри конкретной категории товаров), а это приведёт к ещё большему усложнению и разрастанию класса.

Как лучше поступить?

Добавить фильтрацию без изменения поиска, например, с помощью паттерна «Декоратор» (тут подробнее об этом)

В таком случае на экранах, где доступен только поиск, работаем с SearchViewModel. Там, где к поиску нужно добавить фильтры, — с FilterableSearchViewModel, а при инициализации следует передать SearchViewModel в качестве wrappee. Для вложенных фильтров wrappee для FilterableSearchViewModel может стать другой инстанс FilterableSearchViewModel.

protocol Searchable {
    func search(with queryText: String)
}

class SearchableDecorator: Searchable {
    private let wrappee: Searchable

    required init(wrappee: Searchable) {
        self.wrappee = wrappee
    }

    func search(with queryText: String) {
        wrappee.search(with: queryText)
    }
}

class FilterableSearchViewModel: SearchableDecorator {
    override func search(with queryText: String) { … }

    func addFilters(_ filers: [Filter]) { … }
}

LSP (Liskov Substitution)

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

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

Нарушение LSP — делать неработоспособным один из методов при наследовании — бросать исключение, возвращать пустые данные. 

Признак нарушения LSP — проверка на соответствие типу (item as? MyClass) в местах, где можно работать и с базовым классом, и с его подклассом. Это раскрывает структуру наследования и заставляет клиентский код под нее подстраиваться.

Преимущества:

  • логичное и ожидаемое поведение сущностей;

  • лёгкая замена типов (гибкость и масштабируемость кода);

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

Кейс №4

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

class Product {
    let name: String
    let price: Decimal
    let availability: [Availability]
    let weight: Double

    init(
        name: String,
        price: Decimal,
        availability: [Availability],
        weight: Double
    ) {
        self.name = name
        self.price = price
        self.availability = availability
        self.weight = weight
    }
}

class DigitalProduct: Product {
    let accessUrl: URL

    init(name: String, price: Decimal, accessUrl: URL) {
        self.accessUrl = accessUrl
        super.init(
            name: name,
            price: price,
            availability: [],
            weight: 0
        )
    }
}

Допустим, в приложении нельзя оформить заказ, пока в корзине есть товары, которых нет в наличии.

func checkAllProductsAvailable(_ products: [Product]) -> Bool {
    !products.contains(where: \.availability.isEmpty)
    // При наличии в корзине цифрового товара
    // оформление заказа будет заблокировано,
    // так как его «нет в наличии» (availability == [])
}

let products: [Product] = [
    Product(
        name: "Телевизор",
        price: 30000,
        availability: [
            .init(
                store: .init(address: "Ленина, 1"),
                amount: 4)
        ],
        weight: 15
    ),
    DigitalProduct(
        name: "Книга по SwiftUI",
        price: 1000,
        accessUrl: URL(string: "...")!
    )
]

checkAllProductsAvailable(products)

Чтобы исправить ошибку, Гоша немного изменил метод.

func checkAllProductsAvailable(_ products: [Product]) -> Bool {
    !products.contains { product in
        product as? DigitalProduct == nil &&
        product.availability.isEmpty
    }
}

Какие могут возникнуть проблемы?

Это нарушает LSP. Электронный товар предоставляет меньше, чем его базовый тип. Он возвращает пустые значения, которые могут неожиданно вылезти на этапе оформления заказа. А при добавлении новых типов в иерархию придётся каждый раз изменять зависящий от нее клиентский код, что нарушает OCP.

Как лучше поступить?

Вынести общее поведение в интерфейс Product (или абстрактный класс, но в Swift их нет) и реализовать его в двух отдельных сущностях: физическом и цифровом товарах. 

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

protocol Product {
    var name: String { get }
    var price: Decimal { get }
}

protocol AvailabilityCheckable {
    var availability: [Availability] { get }
}

protocol Weighable {
    var weight: Double { get }
}

protocol URLAccessible {
    var accessUrl: URL { get }
}

struct PhysicalProduct: Product, AvailabilityCheckable, Weighable {
    let name: String
    let price: Decimal
    let availability: [Availability]
    let weight: Double
}

struct DigitalProduct: Product, URLAccessible {
    let name: String
    let price: Decimal
    let accessUrl: URL
}


func checkAllProductsAvailable(_ products: [AvailabilityCheckable]) -> Bool {
    !products.contains(where: \.availability.isEmpty)
}

let products: [Product] = [
    PhysicalProduct(
        name: "Телевизор",
        price: 30000,
        availability: [
            .init(
                store: .init(address: "Ленина, 1"),
                amount: 4)
        ],
        weight: 15
    ),
    DigitalProduct(
        name: "Книга по SwiftUI",
        price: 1000,
        accessUrl: URL(string: "...")!
    )
]

checkAllProductsAvailable(products.compactMap { $0 as? AvailabilityCheckable } )

ISP (Interface Segregation)

Вызывающий код не должен зависеть от интерфейсов, которые он не использует

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

ISP во многом перекликается с SRP, поскольку оба принципа говорят о сужении количества обязанностей сущностей. И помним: интерфейс — это принадлежность клиента, который сам говорит, что ему нужно. 

Нарушение ISP — создание огромных интерфейсов-фасадов для внешних систем, модулей, сущностей.

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

Преимущества:

  • упрощается код и контракты взаимодействия;

  • удобнее создавать тестовые mock-объекты;

  • уменьшается связанность и дублирование;

  • появляется наглядное разделение возможностей, обязанностей и доступов;

  • становится легче модифицировать и расширять функционал.

Кейс №5

Гоше поручили вынести большой кусок функциональности по взаимодействию с платежной системой в отдельный модуль и настроить с ним работу. 

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

protocol PaymentService {
    var currentSettings: PaymentSettings { get }
    func setup(key: String)
    func pay(orderId: String, login: String)
    func generatePaymentUrl(orderId: String, login: String) -> URL
    func generatePaymentQR(orderId: String, login: String) -> String
    func checkPaymentStatus(orderId: String, login: String) -> Bool
    func getPaycheckForOrderWith(id: String, login: String) -> URL
    // ...
}

Какие могут возникнуть проблемы?

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

Как лучше поступить?

Разбить общий интерфейс на несколько специализированных. Они необходимы для разных сценариев взаимодействия с модулем.

protocol PaymentServiceConfigurating {
    var currentSettings: PaymentSettings { get }
    func setup(key: String)
}

protocol PaymentInitializing {
    func pay(orderId: String, login: String)
    func generatePaymentUrl(orderId: String, login: String) -> URL
    func generatePaymentQR(orderId: String, login: String) -> String
}

protocol PaymentResultProcessing {
    func checkPaymentStatus(orderId: String, login: String) -> Bool
    func getPaycheckForOrderWith(id: String, login: String) -> URL
}

typealias PaymentService = PaymentServiceConfigurating &
                           PaymentInitializing &
                           PaymentResultProcessing

DIP (Dependency Inversion)

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

  • модули верхнего уровня — это объекты, которые обращаются к другому объекту, иными словами, вызывающие объекты;

  • модули нижнего уровня — тут, наоборот, вызываемые;

  • абстракция — интерфейс взаимодействия;

  • детали — специфические характеристики работы вызываемого объекта.


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

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

Нарушение DIP — использование классов напрямую. 

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

Преимущества:

  • уменьшается связанность и, как следствие, импакт и количество багов,

  • становится проще модифицировать или изменять функционал;

  • упрощается переиспользуемость кода.

Кейс №6

Гоша — молодец! В рамках разработки фильтрации он учёл все комментарии из кейса №2 и сделал всё, как надо: выделил интерфейс ViewModel для поиска и добавил обертку с фильтрацией. 

Гоша создал модель фильтра, которую нужно передавать в метод фильтрации.

struct FilterOption {
    let id: String
    let title: String
    let isSelected: Bool
}

struct Filter {
    let id: String
    let title: String
    let options: [FilterOption]
    var isActive: Bool {
        options.filter(\.isSelected).count > 0
    }
}

class FilterableSearchViewModel: SearchableDecorator {
    override func search(with queryText: String) { … }
    
    func addFilters(_ filers: [Filter]) { … }
}

Какие могут возникнуть проблемы?

И что бы вы думали? Такое решение не соответствует DIP. Интерфейс фильтрации жестко зависит от модели фильтра. В дальнейшем могут появится другие типы фильтров: фильтр с выбором диапазона значений (выбор цены или габаритов) или фильтр-категория. Логика работы этих моделей отличается. Например, разное поведение при подсчёте количества примененных фильтров.

Как лучше поступить?

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

protocol Filter {
    var id: String { get }
    var title: String { get }
    var selectedOptionsCount: Int { get }
    var values: [String] { get }
}

struct CheckmarkFilter: Filter {
    let id: String
    let title: String
    let options: [CheckmarkFilterOption]

    var selectedOptionsCount: Int {
        options.filter(\.isSelected).count
    }

    var values: [String] {
        options.filter(\.isSelected).map(\.title)
    }
}

struct RangeFilter: Filter {
    let id: String
    let title: String
    let minValue: Double
    let maxValue: Double
    let minRange: Double
    let maxRange: Double

    var selectedOptionsCount: Int {
        (minValue > minRange || maxValue < maxRange) ? 1 : 0
    }

    var values: [String] {
        [minValue, maxValue].map { String($0) }
    }
}

Заключение

Принципы SOLID – это не правила, которым необходимо беспрекословно следовать. Не обязательно обращаться ко всем сущностям через интерфейсы, чтобы следовать DIP или OCP. Более того, классы не должны в обязательном порядке уменьшиться до одного метода и нескольких полей во славу SRP. 

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

Больше полезного про iOS-разработку — в Telegram-канале Surf iOS Team. 

Кейсы, лучшие практики, новости и вакансии в команду iOS Surf в одном месте. Присоединяйтесь!

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


  1. FreeNickname
    04.07.2024 11:53
    +2

    Спасибо за статью! На примерах очень удобно)

    Я тоже использую SOLID в iOS-разработке!
    // Liskov's substitution principle is for the weak.
    @available(*, unavailable)
    override init() {
        fatalError("Never to be called")
    }


  1. Bardakan
    04.07.2024 11:53
    +1

    Важно находить золотую середину между hardcode и softcode и принимать взвешенные и оправданные решения.

    Как бы вы реализовывали код из примера с PhysicalProduct/DigitalProduct, если бы видов продуктов было гораздо больше, а также один вид мог переходить/превращаться в другой вид?