Управление состоянием SwiftUI потрясающее ... но за это приходится платить.

Во время WWDC 2015 Apple произвела фурор на сообщество разработчиков Swift во время сеанса под названием «Протоколно-ориентированное программирование в Swift». В нем Apple продемонстрировала, как протокол-ориентированное программирование (POP) может освободить нас от ограничений традиционной иерархии объектов на основе классов.

С тех пор Swift изменился.

Затем, чуть более года назад, во время WWDC 2019, Apple отказалась от еще одной ядерной бомбы и представила SwiftUI - предлагаемую замену пользовательского интерфейса для всегда почтенных UIKit и AppKit.

Есть только одна проблема.

SwiftUI и программирование, ориентированное на протокол, не очень хорошо ладят.

Чтобы понять, почему, давайте начнем с примера.

SwiftUI и ObservableObject

Допустим, мы начали новый проект SwiftUI и пишем YAARR: Another-Another-Another-RSS-Reader.

На самом базовом уровне YAARR потребуется наблюдаемый объект, который предоставляет список статей. Допустим, этот объект сейчас выглядит следующим образом:

class FeedProvider: ObservableObject {
    @Published var title: String = ""
    @Published var articles: [Article] = []
    func load() {
        // eventually sets title and articles
    }
}

Стандартный, повседневный, заурядный SwiftUI. Вызовите load(), и в конечном итоге будут установлены два опубликованных значения, и наше приложение увидит изменения и отобразит наш список статей RSS. Чудесно.

Но это была статья о протокольно-ориентированном программировании. Что ж, одно из наиболее распространенных применений POP - создание протоколов, позволяющих имитировать данные. Давайте превратим FeedProvider в протокол и сделаем так, чтобы наш объект соответствовал ему.

protocol FeedProviding: ObservableObject {
    var title: String { get }
    var articles: [Article] { get }
    func load()
}
class FeedProvider: ObservableObject, FeedProviding {
    ...
}

Достаточно прямолинейно. Но как только мы попытаемся это использовать, у нас начинаются проблемы.

struct FeedView: View {
    @EnvironmenObject var feed: FeedProviding
    var body: some View {
        Text(feed.title)
    }
}

Введите приведенный выше код, и Xcode немедленно выдаст вам две ошибки в строке EnvironmentObject.

Property type 'FeedProviding' does not match that of the 'wrappedValue' property of its wrapper type 'EnvironmentObject'
Protocol 'FeedProviding' can only be used as a generic constraint because it has Self or associated type requirements

Вторая из двух ошибок - это та, которая вызывает мурашки по спине любого опытного разработчика iOS Swift: ужасная self or associated type requirements проблема. Но наш объектный код выглядит нормально. Мы не делаем ничего из этой мерзкой чепухи ассоциированного типа. Или мы?

К сожалению, мы это делаем, потому что для правильной работы со SwiftUI наш протокол должен быть ObservableObject, а который содержит связанный тип.

public protocol ObservableObject : AnyObject {
    associatedtype ObjectWillChangePublisher
      : Publisher = ObservableObjectPublisher 
        where Self.ObjectWillChangePublisher.Failure == Never
    var objectWillChange: Self.ObjectWillChangePublisher { get }
}

Если наш тип не ObservableObject, то его нельзя использовать как StateObject или передавать и использовать в объектах среды.

Что делать?

Определение связанного типа

Вернемся назад и еще раз посмотрим на проблему. Мы терпим неудачу, потому что нам не хватает ассоциированного типа… но что происходит, когда мы даем ему такой тип?

struct FeedView<F>: View where F: FeedProviding {
    @EnvironmentObject var feed: F
    var body: some View {
        FeedTitleView(title: feed.title)
    }
}

Немного некрасиво… но это работает, и наши ошибки исчезают! Здесь. К сожалению, мы просто пошли дальше, потому что Swift теперь жалуется на наш контейнер.

struct ContentView: View {
    @StateObject var provider = FeedProvider()
    var body: some View {
        FeedView()
            .environmentObject(provider)
            .onAppear {
                provider.load()
            }
    }
}

Следующая ошибка теперь находится в строкеFeedView(), где говорится, что наш недавно созданный generic parameter 'F' could not be inferred. Отлично. Мы можем исправить это, явно указав тип ...

FeedView<FeedProvider>()

Но ... это означает, что мы теперь закодировали тип в нашем коде, и его нельзя изменить.

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

Мы не можем использовать some FeedProviding в качестве результата функции, потому что функция по-прежнему сможет возвращать только один тип.

Мы не можем использовать систему внедрения зависимостей, такую ​​как Resolver, потому что мы снова натолкнемся на косяки проблемы self or associated type requirements, когда Resolver попытается вывести требуемый тип.

Короче говоря, ObservableObjects и протоколы просто несовместимы.

И это в значительной степени убивает использование объектов на основе протокола в SwiftUI.

Или нет?

Если ты не можешь их победить ...

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

Но что, если мы добавим уровень косвенности? Что, если бы наши наблюдаемые объекты содержали наши протоколы? Рассмотрим следующий протокол…

protocol FeedLoading {
    func load(_ handler: @escaping (_ title: String, _ articles: [Article]) -> Void)
}

А затем наблюдаемый объект ...

class FeedProvider: ObservableObject {
    lazy var loader: FeedLoading = FeedLoader()
    @Published var feed: String = ""
    @Published var articles: [Article] = []
    func load() {
        loader.load {
            self.title = $0
            self.articles = $1
        }
    }
}

Здесь я использовал простое закрытие обратного вызова вместо Combine или какой-либо другой более продвинутой реактивной техники, но принцип тот же.

Наш ObservableObject снова является простым наблюдаемым объектом. Но так уж случилось, что наш FeedProvider делегирует большую часть своей работы FeedLoader службе, когда вызывается функция загрузки нашего наблюдаемого объекта.

Наш FeedLoader является экземпляром нашего FeedLoading протокола. Здесь я сделал это простой отложенной переменной на FeedProvider, чтобы ее можно было заменять по мере необходимости, но в производственном коде я, вероятно, внедрил бы загрузчик с помощью Resolver.

class FeedProvider: ObservableObject {
    @Injected var loader: FeedLoading
    ...
}

С учетом вышеизложенного, мы снова видим простой SwiftUI.

struct FeedView: View {
    @EnvironmenObject var feed: FeedProvider
    var body: some View {
        Text(feed.title)
    }
}

Мокинг прост: просто дайте нашему наблюдаемому объекту другой загрузчик. Фактически, чтобы упростить работу с различными предварительными версиями, я, вероятно, определил бы стандартную фиктивную версию своего провайдера.

extension FeedProvider {
    static var mock: FeedProvider = {
        let provider = FeedProvider()
        provider.loader = MockFeedLoader()
        provider.load()
        return provider
    }()
}

Потребляться по мере необходимости.

struct FeedView_Previews: PreviewProvider {
    static var previews: some View {
        return FeedView()
            .environmentObject(FeedProvider.mock)
    }
}

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

Вы также можете создать несколько фиктивных загрузчиков и версий для тестирования различных сценариев (без статей, одна статья, несколько статей, ошибки и т. Д.). Это может очень пригодиться при использовании нескольких превью и в ваших модульных тестах. (Вы выполняете модульные тесты, не так ли?)

Это не только для насмешек ...

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

  • Какие мои каналы поступают из разных источников?
  • Что, если моим фидам требуются разные API?
  • Что, если мои каналы основаны на разных протоколах? (Атом и др.)
  • Что, если некоторые из моих каналов кэшированы?

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

Лучшая практика

И последнее и не менее важное. Это просто лучше ООП.

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

Фактически, исходная проблема является прямым следствием старого вопроса дизайна Is-a против Has-a:

  • Лучше всего описать мой объект как являющийся экземпляром A?
  • Или мне было бы лучше, если бы мой объект содержал экземпляр A?

В этом случае содержащий экземпляр моего протокола решил мою проблему и также является лучшим примером ООП-дизайна.

Беспроигрышный вариант.

Завершение блока

Все в порядке. Я признаю это. Я преувеличил. SwiftUI не убивает протокольно-ориентированное программирование.

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

В таком случае просто назовите меня торговцем оружием.

До следующего раза.

Прочитать всю серию SwiftUI