Недавно я наткнулся на интересный пост на сабреддите 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, потому что они уже были достаточно надежными: возможно, я бы запрограммировал их по-другому с нуля, но они хорошо написаны и прекрасно работают.
Читайте также:
- Использование стека навигации SwiftUI для идеального поведения TabView
- Swift: 7 секретов оптимизации
- Подробный разбор фреймворка Observation. Новый способ повысить производительность SwiftUI
Читайте нас в Telegram, VK и Дзен
Перевод статьи Alex Thurston: Clean Code Review: Removing All the Extra Types