Недавно я наткнулся на интересный пост в сабреддите r/swift, в котором был приведен пример проекта Чистый код, что случается не слишком часто. Я был заинтригован и решил проверить это на GitHub, затем скачал, настроил и попробовал сам. На первый взгляд код был громоздким для чтения, и я был несколько сбит с толку, но после того, как я скачал его и провел в нем некоторое время, все кусочки сложились воедино, и проект, похоже, выполнил то, что пытался выполнить.

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

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

Сетевой уровень — удаление вложенности и типов

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

Это происходит (примерно) так:

NetworkManager -> RequestManager -> RequestProtocol -> DataParser -> DataSource -> Repository -> UseCase

Все еще со мной? Каждый из этих типов отвечает за часть сетевого процесса, например, DataParser отвечает за анализ данных. Это означает, что если вы хотите изменить способ анализа данных, вы можете сделать это, передав новый DataParser — это сделает код компонуемым, и это хорошо!

Однако это довольно сложно читать, потому что они вложены друг в друга таким образом, что трудно понять общую картину. Каждый из них живет в своем собственном файле, и многие из них внедряются через Swinject Resolver, что делает сборку воедино того, как это работает, на удивление сложным. Как выразился один из комментаторов в r/swift, это добавляет в код уровень «косвенности».

Даже после добавления всех этих протоколов и типов для повышения гибкости кода большинство из них просто имеют значения по умолчанию, которые даже не передаются через конструкторы. DataParser там просто жестко запрограммировано, и самый странный пример — RequestProtocol.request(), где создание запроса просто создается с помощью метода расширения самого протокола. Добавлять все эти типы и сложности, а затем не использовать преимущества этого — немного стыдно.

Чтобы убрать вложенность и дополнительные типы и протоколы, мы можем ввести новый метод modelFetcher:

    static func modelFetcher<T, U: Codable>(
        createURLRequest: @escaping (T) throws -> URLRequest,
        store: NetworkStore = .urlSession
    ) -> (T) async -> Result<BaseResponseModel<PaginatedResponseModel<U>>, AppError> {
        let networkFetcher = self.networkFetcher(store: store)
        let mapper: (Data) throws -> BaseResponseModel<PaginatedResponseModel<U>> = jsonMapper()
        
        let fetcher = self.fetcher(
            createURLRequest: createURLRequest,
            fetch: { request -> (Data, URLResponse) in
                try await networkFetcher(request)
            }, mapper: { data -> BaseResponseModel<PaginatedResponseModel<U>> in
                try mapper(data)
            })
        
        return { params in
            await fetcher(params)
        }
    }

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

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

    static func characterFetcher(
        store: NetworkStore = .urlSession
    ) -> (CharacterFetchData) async -> Result<BaseResponseModel<PaginatedResponseModel<CharacterModel>>, AppError> {
        let createURLRequest = { (data: CharacterFetchData) -> URLRequest in
            var urlParams = ["offset": "\(data.offset)", "limit": "\(APIConstants.defaultLimit)"]
            if let searchKey = data.searchKey {
                urlParams["nameStartsWith"] = searchKey
            }
            
            return try createRequest(
                requestType: .GET,
                path: "/v1/public/characters",
                urlParams: urlParams
            )
        }
        
        return self.modelFetcher(createURLRequest: createURLRequest)
    }

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

Конкретный пример использования поведения вместо типов показан в том, как мы конвертируем этот протокол + тип из исходного проекта:

protocol NetworkManager {
    func makeRequest(with requestData: RequestProtocol) async throws -> Data
}

class DefaultNetworkManager: NetworkManager {
    private let urlSession: URLSession

    init(urlSession: URLSession = URLSession.shared) {
        self.urlSession = urlSession
    }

    func makeRequest(with requestData: RequestProtocol) async throws -> Data {
        let (data, response) = try await urlSession.data(for: requestData.request())
        guard let httpResponse = response as? HTTPURLResponse,
              httpResponse.statusCode == 200 else { throw NetworkError.invalidServerResponse }
        return data
    }
}

В простой метод:

    static func networkFetcher(
        store: NetworkStore
    ) -> (URLRequest) async throws -> (Data, URLResponse) {
        { request in
            let (data, response) = try await store.fetchData(request)
            
            guard let httpResponse = response as? HTTPURLResponse,
                  httpResponse.statusCode == 200 else {
                throw NetworkError.invalidServerResponse
            }
            
            return (data, response)
        }
    }

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

Другой простой пример — функция jsonMapper. Мы просто создаем сопоставитель JSON и возвращаем его как замыкание, таким образом сохраняя всю гибкость протокола DataParser, но без протокола.

    static func jsonMapper<T: Decodable>() -> (Data) throws -> T {
        let decoder = JSONDecoder()
        decoder.keyDecodingStrategy = .convertFromSnakeCase
        return { data in
            try decoder.decode(T.self, from: data)
        }
    }

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

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

Модульность проекта

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

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

Кроме того, сетевому модулю вообще не нужно знать о домене, и было бы неплохо удалить зависимость ArkanaKeys от проекта и добавить модуль Network, поскольку это единственное место, где он используется. Наличие полностью изолированного сетевого модуля позволит нам легко повторно использовать всю сетевую логику в любом приложении, которое мы делаем с супергероями Marvel.

В моем примере кода я сделал «виртуальную модуляризацию» — на самом деле я не создавал отдельную структуру для сетевого модуля и не перемещал туда ArkanaKeys в зависимости. Вместо этого я создал папку и добавил контроль доступа, который имитирует то, что было бы, если бы это был совершенно отдельный фреймворк. Это сделано для упрощения демонстрационного проекта, но вы можете просто создать структуру и добавить ее в проект для достижения этой цели.

Другой высокой целью было бы разделить логику пользовательского интерфейса и представления, но на данный момент они довольно связаны, и я думаю, что это нормально. Я удалил папку «Презентация» и переместил ее вместе с пользовательским интерфейсом, потому что на данный момент трудно представить использование HomeViewModel для чего-то другого, кроме HomeView, но это вопрос организационного вкуса.

В итоге я использовал простой класс Container вместо Swinject, но это тоже дело вкуса. В любом случае преобразователь/контейнер должен избегать попыток разрешить такое большое количество конкретных типов сетей, как NetworkManager, DataSource, Repositories и UseCases. В этом случае давайте внедрим NetworkStore (моя замена NetworkManager) и напрямую разрешим зависимости UseCase.

Незначительные обновления слоя пользовательского интерфейса

Я хотел упомянуть некоторые незначительные обновления уровня пользовательского интерфейса, которые повысят читаемость и производительность за счет уменьшения отступов и удаления типа AnyView. Извлечение View из body для удобочитаемости, на мой взгляд, помогает уменьшить отступы до нескольких уровней, если это возможно. Оригинальное приложение имеет 13 уровней отступов в HomeView, что довольно много! Кроме того, это корень приложения, поэтому хорошей идеей будет сделать его как можно более читабельным с самого начала. Мы можем довольно легко уменьшить отступ всего до пяти уровней, извлекая homeView в вычисляемое свойство. Вот как это выглядит::

    public var body: some View {
        NavigationStack {
            ZStack {
                BaseStateView(
                    viewModel: viewModel,
                    successView: homeView,
                    emptyView: BaseStateDefaultEmptyView(),
                    createErrorView: { errorMessage in
                        BaseStateDefaultErrorView(errorMessage: errorMessage)
                    },
                    loadingView: BaseStateDefaultLoadingView()
                )
            }
        }
        .task {
            await viewModel.loadCharacters()
        }
    }

Последнее, что я хотел упомянуть, это то, что приложение использует BaseStateView, которое принимает четыре разных AnyView представления для различных состояний приложения, таких как успех, пусто, ошибка и т. д. BaseStateView было бы удобнее использовать дженерики вместо AnyView которые не всегда хорошо работает для SwiftUI. Это повысит производительность, но недостатком является то, что это заставляет нас передавать именно те View , которые мы хотим для успеха/пусто/создать/загрузить, вместо того, чтобы автоматически делать это за нас в конструкторе.

struct BaseStateView<S: View, EM: View, ER: View, L: View>: View {
    @ObservedObject var viewModel: ViewModel
    let successView: S
    let emptyView: EM?
    let createErrorView: (_ errorMessage: String?) -> ER?
    let loadingView: L?
...

(Если буквы сбивают с толку, вместо них можно использовать имена, например SuccessView, EmptyView и т. д.)

Такой подход с использованием одного базового контроллера/представления кажется немного странным в SwiftUI. Кажется более естественным использовать композицию и добавлять все эти обработчики состояния как ViewModifiers вместо того, чтобы добавлять их все непосредственно в базу View . Но у каждого есть свои компромиссы, и если вы действительно хотите, чтобы этот конструктор напоминал вызывающим об их использовании, когда это применимо, это достойный способ сделать это (и использовать меньше ZStacks)!

struct ErrorStateViewModifier<ErrorView: View>: ViewModifier {
    @ObservedObject var viewModel: ViewModel
    let errorView: (String) -> ErrorView
    
    func body(content: Content) -> some View {
        ZStack {
            content
            if case .error(let message) = viewModel.state {
                errorView(message)
            }
        }
    }
}

Заключение

Большое спасибо mohaned_y98 за вдохновение и прекрасный образец проекта! В этой статье принципы чистой архитектуры применены в стиле, отличном от оригинального проекта. Архитектура исходного проекта имеет свои плюсы и минусы по сравнению с моей переделкой, и в зависимости от потребностей вашего проекта вы можете предпочесть эти компромиссы.

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

Было бы забавно пройтись по всем модульным тестам в другой статье, потому что эта и так становится слишком длинной!

И последнее: всегда легче критиковать чужую работу, чем свою. Что я пропустил в своем примере проекта? Что бы вы сделали по-другому? Оставьте комментарий ниже! Спасибо за прочтение!

Примеры проектов

"Мой проект"

Оригинальный проект

Нажмите на картинку, чтобы просмотреть исходный пост в сабреддите Swift: