Notice
Recent Posts
Recent Comments
Link
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | 5 | 6 | 7 |
8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 | 16 | 17 | 18 | 19 | 20 | 21 |
22 | 23 | 24 | 25 | 26 | 27 | 28 |
29 | 30 | 31 |
Tags
- Protocol
- Clean Code
- swift documentation
- ribs
- rxswift
- ios
- uitableview
- 스위프트
- 리펙터링
- Observable
- Refactoring
- map
- 리펙토링
- Xcode
- uiscrollview
- Human interface guide
- 애니메이션
- collectionview
- UITextView
- clean architecture
- RxCocoa
- combine
- HIG
- 클린 코드
- swiftUI
- SWIFT
- tableView
- UICollectionView
- MVVM
- 리팩토링
Archives
- Today
- Total
김종권의 iOS 앱 개발 알아가기
[iOS - swift] Moya, RxMoya, Networking 레이어 본문
해당 글에서 편리함을 위해 사용된 다른 프레임워크 참고
- RxSwift, RxCocoa
- RxDataSources
- Kingfisher
- Reusable
- Then
Moya 프레임워크
- Alamofire를 Wrapping한 모듈 (Moya는 직접적인 네트워킹을 수행하지 않고 단순히 Alamofire의 추상화)
- Moya로 구현한 layer가 Networking layer 자체가 되므로, 별도의 Networking layer를 만들지 않아도 되는 간편함이 존재
- Moya는 Request에 관한 endpoint가 enum으로 정의하는 구조이므로 type-safe 방식으로 네트워킹 요청가능
- testable한 네트워킹 레이어 구축이 편리 (stub 이용)
예제에서 사용할 API
- Flickr API: 사진 공개 피드 api
- url: https://api.flickr.com/services/feeds/photos_public.gne?format=json&nojsoncallback=1
Moya - Request 할 때 필요한 Endpoint 정의 (TargetType)
- Error 처리 시 사용되는 공통 기능 정의 "Moya+NetworkError.swift"
// Moya+NetworkError.swift import Alamofire import Moya extension TargetType { static func converToURLError(_ error: Error) -> URLError? { switch error { case let MoyaError.underlying(afError as AFError, _): fallthrough case let afError as AFError: return afError.underlyingError as? URLError case let MoyaError.underlying(urlError as URLError, _): fallthrough case let urlError as URLError: return urlError default: return nil } } static func isNotConnection(error: Error) -> Bool { Self.converToURLError(error)?.code == .notConnectedToInternet } static func isLostConnection(error: Error) -> Bool { switch error { case let AFError.sessionTaskFailed(error: posixError as POSIXError) where posixError.code == .ECONNABORTED: // eConnAboarted: Software caused connection abort. break case let MoyaError.underlying(urlError as URLError, _): fallthrough case let urlError as URLError: guard urlError.code == URLError.networkConnectionLost else { fallthrough } // A client or server connection was severed in the middle of an in-progress load. break default: return false } return true } }
- Request - TargetType 프로토콜을 준수하는 enum 정의
- API endpoint 케이스 정의 "MyAPI.swift"
- getBaseURL(), getPath(), getMethod(), getTask()는 별도의 파일에서 정의
- 별도의 파일에서 task, request, path, method, baseURL을 정의하여 get-으로 사용하는 이유는, case가 많아지면 복잡해지므로 역할의 분리를 위함
// MyAPI.swift import Moya enum MyAPI { case getPhotos(PhotoRequest) } // MARK: MyAPI+TargetType extension MyAPI: Moya.TargetType { var baseURL: URL { self.getBaseURL() } var path: String { self.getPath() } var method: Method { self.getMethod() } var sampleData: Data { Data() } var task: Task { self.getTask() } var headers: [String : String]? { ["Content-Type": "application/json"] } }
- Request할 때 필요한 데이터를 불러오는 (task) 메소드 정의 "MyAPI+Task"
// MyAPI+Task.swift import Moya extension MyAPI { func getTask() -> Task { switch self { case .getPhotos(let request): return .requestParameters(parameters: request.toDictionary(), encoding: URLEncoding.queryString) } } } extension Encodable { func toDictionary() -> [String: Any] { do { let jsonEncoder = JSONEncoder() let encodedData = try jsonEncoder.encode(self) let dictionaryData = try JSONSerialization.jsonObject( with: encodedData, options: .allowFragments ) as? [String: Any] return dictionaryData ?? [:] } catch { return [:] } } }
- MoyaProvider인스턴를 이용하여 request하는 기능 정의 + Error Handleing "MyAPI+Request"
- Error Type 정의
// MyAPI+Request.swift import RxSwift import Moya import Alamofire import Then // MARK: Error Type enum MyAPIError: Error { case empty case requestTimeout(Error) case internetConnection(Error) case restError(Error, statusCode: Int? = nil, errorCode: String? = nil) var statusCode: Int? { switch self { case let .restError(_, statusCode, _): return statusCode default: return nil } } var errorCodes: [String] { switch self { case let .restError(_, _, errorCode): return [errorCode].compactMap { $0 } default: return [] } } var isNoNetwork: Bool { switch self { case let .requestTimeout(error): fallthrough case let .restError(error, _, _): return MyAPI.isNotConnection(error: error) || MyAPI.isLostConnection(error: error) case .internetConnection: return true default: return false } } }
- wrapper 정의
- 해당 부분에서 PluginType을 삽입
(주의: Logger Plugin은 따로 넣지 않고, #file, #line, #function을 파라미터로 받아서 해당 위치도 알 수 있게끔 request하는 곳에서 직접 로깅)
// MyAPI+Request.swift // MARK: Moya Wrapper extension MyAPI { struct Wrapper: TargetType { let base: MyAPI var baseURL: URL { self.base.baseURL } var path: String { self.base.path } var method: Moya.Method { self.base.method } var sampleData: Data { self.base.sampleData } var task: Task { self.base.task } var headers: [String : String]? { self.base.headers } } private enum MoyaWrapper { struct Plugins { var plugins: [PluginType] init(plugins: [PluginType] = []) { self.plugins = plugins } func callAsFunction() -> [PluginType] { self.plugins } } static var provider: MoyaProvider<MyAPI.Wrapper> { let plugins = Plugins(plugins: []) let configuration = URLSessionConfiguration.default configuration.timeoutIntervalForRequest = 30 configuration.urlCredentialStorage = nil let session = Session(configuration: configuration) return MoyaProvider<MyAPI.Wrapper>( endpointClosure: { target in MoyaProvider.defaultEndpointMapping(for: target) }, session: session, plugins: plugins() ) } } }
- 해당 부분에서 PluginType을 삽입
- Error Handling 정의
- 인터넷 연결 에러, TimeOut 에러, 일반 에러를 처리하는 메소드 정의
(request의 응답값에서 해당 에러처리 메소드를 부르도록 설계)
// Moya+Request.swift // MARK: Error Handling extension MyAPI { private func handleInternetConnection<T: Any>(error: Error) throws -> Single<T> { guard let urlError = Self.converToURLError(error), Self.isNotConnection(error: error) else { throw error } throw MyAPIError.internetConnection(urlError) } private func handleTimeOut<T: Any>(error: Error) throws -> Single<T> { guard let urlError = Self.converToURLError(error), urlError.code == .timedOut else { throw error } throw MyAPIError.requestTimeout(urlError) } private func handleREST<T: Any>(error: Error) throws -> Single<T> { guard error is MyAPIError else { throw MyAPIError.restError( error, statusCode: (error as? MoyaError)?.response?.statusCode, errorCode: (try? (error as? MoyaError)?.response?.mapJSON() as? [String: Any])?["code"] as? String ) } throw error } }
- 인터넷 연결 에러, TimeOut 에러, 일반 에러를 처리하는 메소드 정의
- request하는 메소드 정의
- 인수로 #file, #function, #line을 받기 때문에 별도의 LoggerPlugin을 사용하지 않고 해당 request에서 로그 출력
- log를 해당 부분에서 출력
// MARK: Moya Request extension MyAPI { static let moya = MoyaWrapper.provider static var jsonDecoder: JSONDecoder { let decoder = JSONDecoder() return decoder } func request( file: StaticString = #file, function: StaticString = #function, line: UInt = #line ) -> Single<Response> { let endpoint = MyAPI.Wrapper(base: self) let requestString = "\(endpoint.method) \(endpoint.baseURL) \(endpoint.path)" return Self.moya.rx.request(endpoint) .filterSuccessfulStatusCodes() .catch(self.handleInternetConnection) .catch(self.handleTimeOut) .catch(self.handleREST) .do( onSuccess: { response in let requestContent = "🛰 SUCCESS: \(requestString) (\(response.statusCode))" print(requestContent, file, function, line) }, onError: { rawError in switch rawError { case MyAPIError.requestTimeout: print("TODO: alert MyAPIError.requestTimeout") case MyAPIError.internetConnection: print("TODO: alert MyAPIError.internetConnection") case let MyAPIError.restError(error, _, _): guard let response = (error as? MoyaError)?.response else { break } if let jsonObject = try? response.mapJSON(failsOnEmptyData: false) { let errorDictionary = jsonObject as? [String: Any] guard let key = errorDictionary?.first?.key else { return } let message: String if let description = errorDictionary?[key] as? String { message = "🛰 FAILURE: \(requestString) (\(response.statusCode)\n\(key): \(description)" } else if let description = (errorDictionary?[key] as? [String]) { message = "🛰 FAILURE: \(requestString) (\(response.statusCode))\n\(key): \(description)" } else if let rawString = String(data: response.data, encoding: .utf8) { message = "🛰 FAILURE: \(requestString) (\(response.statusCode))\n\(rawString)" } else { message = "🛰 FAILURE: \(requestString) (\(response.statusCode)" } print(message) } default: break } }, onSubscribe: { let message = "REQUEST: \(requestString)" print(message, file, function, line) } ) } }
- Error Type 정의
엔터티 - Request / Response Model 정의
- 공통 모델
- Then: 인스턴스 초기화 시 사용하기 편리함을 위해 선언
- Codable: Encodable, Decodable
- Equatable: RxDataSources 사용 시 필요한 종속성
import Then protocol ModelType: Then, Codable, Equatable {}
- Request 모델
- query parameter에 들어갈 값
struct PhotoRequest: ModelType { var tags = "landscape, portrait" var tagmode = "any" var format = "json" var nojsoncallback = "1" }
- query parameter에 들어갈 값
- Response 모델
struct Photo: ModelType { let title: String let link: String let items: [Item] struct Item: ModelType { let title: String let link: String let media: Media let author: String let authorID: String enum CodingKeys: String, CodingKey { case title case link case media case author case authorID = "author_id" } static func == (lhs: Item, rhs: Item) -> Bool { lhs.link == rhs.link } struct Media: Codable { let m: String } } static func == (lhs: Photo, rhs: Photo) -> Bool { lhs.link == rhs.link } }
SectionModel 정의
- RxDataSources에 사용하기 위해 SectionModel 정의
import RxDataSources
enum PhotoSection {
case result([PhotoSectionItem])
}
enum PhotoSectionItem: Equatable {
case result(Photo.Item)
}
extension PhotoSection: SectionModelType {
var items: [PhotoSectionItem] {
switch self {
case .result(let photos): return photos
}
}
init(original: PhotoSection, items: [PhotoSectionItem]) {
switch original {
case .result: self = .result(items)
}
}
}
extension PhotoSection: Equatable {
static func == (lhs: PhotoSection, rhs: PhotoSection) -> Bool {
lhs.items == rhs.items
}
}
Moya API + RxDataSources 연동
- ViewController 준비
// ViewController.swift import UIKit import Then import SnapKit import RxDataSources import RxCocoa import Reusable import RxSwift import Moya class ViewController: UIViewController { }
- 데이터가 저장될 PhotoDataSource 정의
private let photoDataSource = BehaviorRelay<[PhotoSection]>(value: [])
- RxDataSources 정의
// viewDidLoad() 에서 호출 private func setupCollectionViewDataSource() { let collectionViewDataSource = RxCollectionViewSectionedReloadDataSource<PhotoSection> { _, collectionView, indexPath, sectionItem in Self.collectionViewConfigureCell( collectionView: collectionView, indexPath: indexPath, item: sectionItem ) } self.photoDataSource .bind(to: self.collectionView.rx.items(dataSource: collectionViewDataSource)) .disposed(by: disposeBag) } // MARK: DataSources private static func collectionViewConfigureCell( collectionView: UICollectionView, indexPath: IndexPath, item: PhotoSectionItem ) -> UICollectionViewCell { switch item { case let .result(photo): let cell = collectionView.dequeueReusableCell(for: indexPath) as PhotoCell cell.setImage(photo: photo) return cell } }
- Moya를 사용한 Networking 요청
- 요청 후 json 형태에 escape characters (백슬래쉬)가 들어있으므로 해당 부분을 제거하는 기능 extension String에 추가
extension String { var removedEscapeCharacters: String { /// remove: \" let removedEscapeWithQuotationMark = self.replacingOccurrences(of: "\\\"", with: "") /// remove: \ let removedEscape = removedEscapeWithQuotationMark.replacingOccurrences(of: "\\", with: "") return removedEscape } }
- Moya를 통해서 Request를 한 후 Decoding 전에 escape를 삭제하는 코드 삽입
// 버튼을 누른 경우 호출 private func loadImage() { let photoRequest = PhotoRequest() MyAPI.getPhotos(photoRequest) .request() .map { let jsonString = try $0.mapString().removedEscapeCharacters guard let value = jsonString.data(using: .utf8) else { return $0 } let newResponse = Response( statusCode: $0.statusCode, data: value, request: $0.request, response: $0.response ) return newResponse } .map(Photo.self, using: MyAPI.jsonDecoder) .asObservable() .bind(onNext: self.updatePhoto) .disposed(by: disposeBag) } private func updatePhoto(_ photo: Photo) { let previusPhotos = photoDataSource.value let newSectionItems = photo.items.map(PhotoSectionItem.result) photoDataSource.accept(previusPhotos + [PhotoSection.result(newSectionItems)]) }
- 요청 후 json 형태에 escape characters (백슬래쉬)가 들어있으므로 해당 부분을 제거하는 기능 extension String에 추가
* 전체 소스 코드: https://github.com/JK0369/ExMoya
'iOS framework' 카테고리의 다른 글
[iOS - swift] AlignedCollectionViewFlowLayout 프레임워크 (CollectionView Cell 정렬) (0) | 2021.12.20 |
---|---|
[iOS - swift] 2. ReactorKit 샘플 앱 - RxDataSources을 이용한 Section, Item 모델 구현 패턴 (with 동적 사이즈 셀) (2) | 2021.12.16 |
[iOS - swift] 1. ReactorKit 샘플 앱 - RxDataSources 사용 방법 (0) | 2021.12.10 |
[iOS - swift] Reusable 프레임워크 사용 방법 (0) | 2021.12.10 |
[iOS - swift] 1. Kingfisher 프레임워크 (이미지 캐싱, 이미지 로드) - 사용 방법 (0) | 2021.12.09 |
Comments