Наше приложение переживает редизайн и добавление новых фич очень даже быстро, во многом, благодаря моему решению несколько недель назад внедрить многомодульность.
Как родилась идея разбить приложение?
Каналы про разработку, на которые я подписан, постоянно публиковали статьи про многомодульность. Мы со знакомыми постоянно обсуждали эту идею. Все вокруг пестрило ей в моем инфополе, но я противился этой мысли.
Во-первых, я не считал что на приложение, в котором на тот момент было 4-5 экранов нэтив и вебвью, необходимы модули. Во-вторых, не понимал какие модули выделить я смогу. В-третьих, боялся взять задачу и не сделать ее.
Но потом я понял - нужно это делать прямо сейчас, или потом будет очень сложно. Я решил, что не хочу разбивать обросшее сложной логикой взаимодействия приложение, а сделаю это сейчас, пока это не так сложно.
Какие модули я решил выделить?
Сетевой слой (Про него сегодня хочется поговорить)
Слой работы с данными на клиенте (Будет во второй части)
Модуль с экраном для тестировщиков (Будет в третьей части)
Сетевой слой
Он у меня реализован с помощью нативного URLSession. Я взял уже не помню откуда идею простейшего сетевого взаимодействия.
В слой я вынес абсолютно все файлы, которые хоть как то связаны с сетью. То есть, вынес реализацию запросов на сервер, реализацию отправки параметров в JS у вебвью, а так же работу с Firebase. Весь модуль я покрыт unit тестами, но сразу сделал их, чтобы можно было проверить и работу с бекендом. Перейдем к реализации.
У меня есть базовый билдер АПИ:
protocol APIBuilder { var urlRequest: URLRequest { get } var baseUrl: URL { get } var path: String { get } }
А есть конкретные реализации:
enum ModelsAPI { case getModelsPerPage(Int) case getModelByIds(Int) } extension ModelsAPI: APIBuilder { var urlRequest: URLRequest { switch self { case .getModelsPerPage(let page): var components = URLComponents(string: baseUrl.appendingPathComponent(path).absoluteString) components?.queryItems = [ URLQueryItem(name: "page", value: "\(page)") ] guard let url = components?.url else { return URLRequest(url: baseUrl.appendingPathComponent(path)) } var request = URLRequest(url: url) request.httpMethod = "POST" return request case .getModelByIds(let id): var request = URLRequest(url: baseUrl.appendingPathComponent(path).appendingPathComponent("\(id)")) request.httpMethod = "GET" return request } } var path: String { return "api/models" } }
При этом есть и ошибки, которые сетевой слой возвращает, в зависимости от ситуации:
/// Custom errors of NetworkLayer public enum APIError: Error { case decodingError case errorCode(Int) case unknown } extension APIError: LocalizedError { public var errorDescription: String? { switch self { case .decodingError: return "APIError: decodingError" case .errorCode(let code): return "APIError: \(code)" case .unknown: return "APIError: unknown" } } }
Сервис, который обрабатывает запрос:
final class NetworkService { func request<T: Codable>(from endpoint: APIBuilder) -> AnyPublisher<T, APIError> { return ApiManager .sharedInstance .dataTaskPublisher(for: endpoint.urlRequest) .receive(on: DispatchQueue.main) .mapError { error in print("error on", error.failingURL) return APIError.unknown } .flatMap { data, response -> AnyPublisher<T, APIError> in guard let response = response as? HTTPURLResponse else { return Fail(error: APIError.unknown).eraseToAnyPublisher() } if (200...299).contains(response.statusCode) { let jsonDecoder = JSONDecoder() return Just(data) .decode(type: T.self, decoder: jsonDecoder) .mapError { _ in APIError.decodingError} .eraseToAnyPublisher() } else { return Fail(error: APIError.errorCode(response.statusCode)).eraseToAnyPublisher() } } .eraseToAnyPublisher() } }
А также реализация ModelsNetworkService:
/// Service to network work of models public class ModelsNetworkService { public init() {} fileprivate lazy var networkService = NetworkService() fileprivate var loadModelByIdResponse: ResponseModel? fileprivate var modelsResponse: ModelsResponseModel? fileprivate var cancellables = Set<AnyCancellable>() /// Method to get single model by id /// - Parameters: /// - modelId: Model id from backend /// - completion: It return's single model or APIError public func loadModel(byId modelId: Int, completion: @escaping (model?, APIError?) -> Void ) { let cancellable = networkService.request(from: ModelsAPI.getModelByIds(modelId)) .sink { [weak self] res in guard let strongSelf = self else { return } switch res { case .finished: guard let model = strongSelf.loadModelByIdResponse!.data else { return } completion(restaurant, nil) case .failure(let error): print("loadModel byIds: \(error.errorDescription)") completion(nil, error) } } receiveValue: { [weak self] response in self?.loadModelByIdResponse = response } cancellables.insert(cancellable) } /// Method to get ModelsResponse by location /// - Parameters: /// - page: Current page to pagination /// - completion: It return's single ModelsResponse or APIError public func loadModels(page: Int, completion: @escaping (ModelsResponse?, APIError?) -> Void ) { let cancellable = networkService .request(from: ModelsAPI.getModelsPerPage(page)) .sink { [weak self] res in guard let strongSelf = self else { return } switch res { case .finished: completion(strongSelf.modelsResponse, nil) case .failure(let error): completion(nil, error) } } receiveValue: { [weak self] response in self?.modelsResponse = response } cancellables.insert(cancellable) } }
Все это я поместил в SPM
import PackageDescription let package = Package( name: "NetworkLayer", platforms: [.iOS(.v13), .macOS(.v10_12)], products: [ // Products define the executables and libraries a package produces, and make them visible to other packages. .library( name: "NetworkLayer", targets: ["NetworkLayer"]), ], dependencies: [ // Dependencies declare other packages that this package depends on. // .package(url: /* package url */, from: "1.0.0"), .package(name: "Firebase", url: "https://github.com/firebase/firebase-ios-sdk.git", from: "7.0.0") ], targets: [ // Targets are the basic building blocks of a package. A target can define a module or a test suite. // Targets can depend on other targets in this package, and on products in packages this package depends on. .target( name: "NetworkLayer", dependencies: [ // The product name you need. In this example, FirebaseAuth. .product(name: "FirebaseAnalytics", package: "Firebase"), .product(name: "FirebaseCrashlytics", package: "Firebase") ], path: "Sources" ), .testTarget( name: "NetworkLayerTests", dependencies: ["NetworkLayer"]), ] )
Теперь же сам тесты:
func testLoadRestaurants() { var modelsMain: [Model]? = nil let expectation = XCTestExpectation.init(description: "testLoadModels") modelsNetworkService.loadModels(page: 1) { [weak self] response, error in if let response = response { modelsMain = response.data expectation.fulfill() } else { XCTFail("Fail") } } wait(for: [expectation], timeout: 30.0) print(modelsMain?.count) XCTAssertTrue(modelsMain?.count ?? 0 > 0) }
Я, также, подключаю firebase, но взаимодействие с ним не вижу смысла показывать.
Этот слой я подключаю к основному проекту и использую таким образом:
import NetworkLayer ... fileprivate lazy var modelsNetworkService = ModelsNetworkService() ... modelsNetworkService.loadModels(page: page) { [weak self] response, error in guard let strongSelf = self else { return } if let response = response { strongSelf.output.loadedModels(modelsResponse: response, meta: response.meta) } else if let error = error { strongSelf.output.loadingModelsError(error: error.localizedDescription) } }
Стоит еще о кое чем рассказать. Раньше была путаница с моделями: какая в сетевой слой, какая во внутренний слой обработки данных. Теперь я вынес модели связанные с сетью в этот же модуль и путаница ушла.
Заключение первой части
В данный момент я выделил от проекта все три модуля и могу сказать, что скорость билда в firebase уменьшилось с 15-20 минут до 3-4 минут максимум. За этим очевидным плюсом скрывается еще то, что архитектура проекта стала более правильной и понятной.
Я стараюсь поддерживать в слоях SOLID и благодаря этому изменения конкретного слоя не влияют на другие слои и основное приложение в фатальном плане.
Надеюсь статья вышла интересной :-) Буду рад критике, и предложениям по улучшению как реализации, так и рассказа.