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