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

Как родилась идея разбить приложение?

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

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

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

Какие модули я решил выделить?

  1. Сетевой слой (Про него сегодня хочется поговорить)

  2. Слой работы с данными на клиенте (Будет во второй части)

  3. Модуль с экраном для тестировщиков (Будет в третьей части)

Сетевой слой

Он у меня реализован с помощью нативного 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 и благодаря этому изменения конкретного слоя не влияют на другие слои и основное приложение в фатальном плане.

Надеюсь статья вышла интересной :-) Буду рад критике, и предложениям по улучшению как реализации, так и рассказа.

Комментарии (4)


  1. 0xFEE1DE4D
    31.10.2021 16:48
    +1

    modelsNetworkService.loadModels(page: 1)
    тесты которые лезут по настоящему в сеть не очень надежны, и не особо приветствуется. лучше замокать networkService, и проверить что при замоканном failure происходит вызов кложуры с верной ошибкой, а при замоканом успехе response не пустой, и в нем есть данные

    и кажется urlRequest в ModelsAPI немного громоздкий получается, возможно стоит абстрагироваться до method и parameters, и уже в абстракции делать URLRequest


    1. chesnikovofficial Автор
      18.11.2021 12:46

      Спасибо за критику, действительно стоит абстрагироваться. Тоже думаю уже над этим.

      По поводу тестов тоже понял, не совсем корректное место для проверки работы сервисов продукта выбрал.


  1. Prostor9
    18.11.2021 12:48

    А почему Вы в loadModels в completion-блоке не используете Result?
    И Вы используете Combine? Не слежу просто за реактивными фреймворками.

    Еще guard let strongSelf = self else { return } можно много где повыкидывать, т.к. все равно с nil работаете и выдаете


    1. chesnikovofficial Автор
      18.11.2021 12:50

      С result начал знакомится, благодаря вашему комментарию, спасибо, удобная вещь. Обязательно нужно будет завезти.

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