Недавно я наткнулся на интересный пост на сабреддите r/swift, в котором приводился пример проекта “Clean Code” (“Чистый код”), что случается не так уж часто. Я был заинтригован этим примером и решил проверить его на 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)
}
}

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

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

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-mapper и возвращаем его в виде замыкания, тем самым сохраняя всю гибкость протокола 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-mapper будет использовать сетевая функция? Можно ли вообще изменить JSON-mapper для сетевой функции, не сломав ее? Было бы неплохо, если бы Network-модуль решал все эти вопросы за нас, чтобы мы могли заниматься тем, для чего он используется  —  фетчингом супергероев.

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

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

В коде моего примера выполнена “виртуальная модуляризация”: я не стал создавать отдельный фреймворк для Network-модуля и переносить туда зависимость ArkanaKeys. Вместо этого создал папку и добавил в нее контроль доступа, тем самым сымитировал то, что было бы, если бы это был совершенно отдельный фреймворк.

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

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

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

Небольшие изменения в UI-слое

Хотел бы упомянуть о некоторых незначительных изменениях в слое пользовательского интерфейса, которые повысят читаемость и производительность за счет уменьшения отступов и удаления типа AnyView. На мой взгляд, извлечение View из body для повышения читабельности помогает уменьшить отступы до нескольких уровней, если это возможно. В оригинальном приложении отступы в HomeView достигают 13 уровней, что довольно много! Кроме того, это корень приложения, поэтому лучше с самого начала сделать его максимально читаемым. Можно легко уменьшить отступы всего до 5 уровней, извлекая 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, указывающих на представления для различных состояний приложения, таких как success, empty, error и т. д. BaseStateView будет “чувствовать” себя более комфортно, если использовать дженерики вместо AnyView, которые не всегда хорошо работают в SwiftUI. Это увеличит производительность, хотя придется передавать именно те View, которые необходимы для состояний success/empty/create/loading (вместо их автоматического создания в конструкторе).

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)
}
}
}
}

Заключение

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

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

Читайте также:

Читайте нас в Telegram, VK и Дзен


Перевод статьи Alex Thurston: Clean Code Review: Removing All the Extra Types

Предыдущая статьяКак язык SudoLang помогает общаться с языковыми моделями. Руководство для новичков
Следующая статьяГрафовые сверточные сети: введение в GNN