LoginSignup
26

More than 3 years have passed since last update.

Combine+Alamofire+SwiftUIでAPI実行

Last updated at Posted at 2020-01-08

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はなんども触ったことがあるがSwiftUICombineに関しては初めてだったので、様々な記事などを参考にしてました。

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()
      }
    }
  }
}

実行結果

image.png

まとめ

  • なんとか汚いながらもCombine+Alamofireを組み合わせで実現することができた。
  • まだエラーハンドリングがかなり適当なのでちゃんとアプリとしてのハンドリング処理を書きたい。
  • MVCでしか書いたことないからMVVMを覚えたい。
  • もっと簡単に書く方法があればコメントください。

参考

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
26