CombineとAlamofireを組み合わせてSwiftUIを実行してみたい。
2019年に発表されたSwiftUIとCombineのフレームワークですが、まだUIKitなどの長年使われてきたもの比べるとまだまだ参考になる記事が少なく、どう実装していくべきかイメージができない...
アプリではAPIなどを実行して動的な画面を作成するのが一般的なので、いつも使っているAlamofire
と組み合わせてUIKitと同様にSwiftUIでも実現できるか試してみた。
Alamofireがいろいろ変わってる...
今回、実装する際にcocoapodsを使用しAlamofireを使用できるようにしたが、いつの間にか記述方法が変わってた...
以前
let parameters: Parameters = ["foo": "bar"]
Alamofire.request(urlString, method: .get, parameters: parameters, encoding: JSONEncoding.default)
.downloadProgress(queue: DispatchQueue.global(qos: .utility)) { progress in
print("Progress: \(progress.fractionCompleted)")
}
.validate { request, response, data in
// Custom evaluation closure now includes data (allows you to parse data to dig out error messages if necessary)
return .success
}
.responseJSON { response in
debugPrint(response)
}
最新 Version:5.0.3(2020/03/15時点)
struct Login: Encodable {
let email: String
let password: String
}
let login = Login(email: "test@test.test", password: "testPassword")
AF.request("https://httpbin.org/post",
method: .post,
parameters: login,
encoder: JSONParameterEncoder.default).response { response in
debugPrint(response)
}
こちらをみるとAlamofire
と書いていたものがAF
変更されています。
マイグレーションガイドがあるので早めにバージョンアップしちゃいましょう。
Alamofire 5.0 Migration Guide
自力で実装してみた!
Alamofire
はなんども触ったことがあるがSwiftUI
とCombine
に関しては初めてだったので、様々な記事などを参考にしてました。
QiitaApiを実行し画面に表示するアプリ
// リクエストとレスポンスのプロトコル
import Combine
import Foundation
import Alamofire
// MARK: - Base API Protocol
protocol BaseAPIProtocol {
associatedtype ResponseType
var method: HTTPMethod { get }
var baseURL: URL { get }
var path: String { get }
var headers: [String : String]? { get }
var allowsConstrainedNetworkAccess: Bool { get }
}
extension BaseAPIProtocol {
var baseURL: URL {
return try! NetworkConstants.baseURL.asURL()
}
var headers: [String : String]? {
return nil
}
var allowsConstrainedNetworkAccess: Bool {
return true
}
}
// MARK: - BaseRequestProtocol
protocol BaseRequestProtocol: BaseAPIProtocol, URLRequestConvertible {
var parameters: Parameters? { get }
var encoding: URLEncoding { get }
}
extension BaseRequestProtocol {
var encoding: URLEncoding {
return URLEncoding.default
}
func asURLRequest() throws -> URLRequest {
var urlRequest = URLRequest(url: baseURL.appendingPathComponent(path))
urlRequest.httpMethod = method.rawValue
urlRequest.allHTTPHeaderFields = headers
urlRequest.timeoutInterval = TimeInterval(30)
urlRequest.allowsConstrainedNetworkAccess = allowsConstrainedNetworkAccess
if let params = parameters {
urlRequest = try encoding.encode(urlRequest, with: params)
}
return urlRequest
}
}
// API実行用のPublisher
import Alamofire
import Combine
import Foundation
// MARK: - NetworkPublisher
struct NetworkPublisher {
// MARK: - Constants
private static let successRange = 200 ..< 300
private static let contentType = "application/json"
private static let retryCount: Int = 1
static let decorder: JSONDecoder = {
let jsonDecoder = JSONDecoder()
jsonDecoder.keyDecodingStrategy = .convertFromSnakeCase
return jsonDecoder
}()
// MARK: - Methods
static func publish<T, V>(_ request: T) -> Future<V, APIError>
where T: BaseRequestProtocol, V: Codable, T.ResponseType == V {
return Future { promise in
let api = AF.request(request)
.validate(statusCode: self.successRange)
.validate(contentType: [self.contentType])
.cURLDescription { text in
print(text)
}
.responseJSON { response in
switch response.result {
case .success:
do {
if let data = response.data {
let json = try self.decorder.decode(V.self, from: data)
promise(.success(json))
} else {
promise(.failure(APIError.error(error: "レスポンスのjsonが空")))
}
} catch {
promise(.failure(APIError.error(error: "マッピングエラー")))
}
case let .failure(error):
promise(.failure(APIError.error(error: error.errorDescription ?? "")))
}
}
api.resume()
}
}
}
// ネットワークに関する定数
import Foundation
class NetworkConstants: NSObject {
// MARK: - Variables
static var baseURL: String = {
#if DEBUG
return "https://qiita.com/api/v2/"
#else
return "https://qiita.com/api/v2/"
#endif
}()
}
// リクエスト
import Alamofire
class ItemsRequest: BaseRequestProtocol {
var parameters: Parameters? {
return nil
}
var method: HTTPMethod {
return .get
}
var path: String {
return "items"
}
var allowsConstrainedNetworkAccess: Bool {
return false
}
typealias ResponseType = [ItemsResponse]
}
// レスポンス
struct ItemsResponse: Codable, Identifiable {
let body : String?
let coediting : Bool?
let commentsCount : Int?
let createdAt : String?
let group : String?
let id : String
let likesCount : Int?
let pageViewsCount : String?
let privateField : Bool?
let reactionsCount : Int?
let renderedBody : String?
let tags : [Tag]
let title : String
let updatedAt : String?
let url : String?
let user : User
struct User: Codable, Identifiable {
let descriptionField : String?
let facebookId : String?
let followeesCount : Int?
let followersCount : Int?
let githubLoginName : String?
let id : String?
let itemsCount : Int?
let linkedinId : String?
let location : String?
let name : String?
let organization : String?
let permanentId : Int?
let profileImageUrl : String?
let teamOnly : Bool?
let twitterScreenName : String?
let websiteUrl : String?
}
struct Tag : Codable {
let name : String?
let versions : [String]?
}
}
// ViewModel
import Combine
import Alamofire
import Foundation
class TopViewModel: ObservableObject {
@Published var displayData: [ItemsResponse] = []
@Published var errorCode: String = ""
@Published var showAlert: Bool = false
var task: AnyCancellable? = nil
private(set) lazy var onAppear: () -> Void = { [weak self] in
self?.getItems()
}
func getItems() {
self.task = NetworkPublisher.publish(ItemsRequest())
.receive(on: DispatchQueue.main)
.sink(receiveCompletion: { completion in
switch completion {
case .finished:
print("成功")
case let .failure(error):
print(error)
self.errorCode = error.localizedDescription
self.showAlert = true
}
},
receiveValue: { data in
self.displayData = data
self.errorCode = ""
self.showAlert = false
})
}
}
// 表示されるView
import SwiftUI
import Combine
struct TopView: View {
@ObservedObject var observed = TopViewModel()
var body: some View {
NavigationView {
List(observed.displayData.indices, id: \.self) { index in
NavigationLink(destination: QiitaView(displayData: self.$observed.displayData[index]),
label: {
TopViewRow(displayData: self.$observed.displayData[index])
})
}
.navigationBarTitle("新着", displayMode: .automatic)
}.onAppear(perform: observed.onAppear)
.alert(isPresented: $observed.showAlert,
content: {
Alert(title: Text("エラー"),
message: Text("\(observed.errorCode)"),
dismissButton: .default(Text("エラー"),
action: {
self.observed.getItems()
}))
}
)
}
}
struct TopViewRow: View {
@Binding var displayData: ItemsResponse
var body: some View {
VStack {
HStack {
Text(displayData.user.id ?? "")
Spacer()
}
HStack {
Text(displayData.title)
Spacer()
}
HStack {
ForEach(displayData.tags, id: \.name) { tag in
Text(tag.name ?? "")
.padding(.horizontal, 5.0)
.padding(.vertical, 3.0)
.background(Color.gray)
.font(Font.system(size: 10, weight: .light, design: .default))
.cornerRadius(5)
}
Spacer()
}
}
}
}
実行結果
まとめ
- なんとか汚いながらもCombine+Alamofireを組み合わせで実現することができた。
- まだエラーハンドリングがかなり適当なのでちゃんとアプリとしてのハンドリング処理を書きたい。
- MVCでしか書いたことないからMVVMを覚えたい。
- もっと簡単に書く方法があればコメントください。