ボクココ

サービス開発を成功させるまでの歩み

SwiftUI と Combine による REST API 通信

ども、@kimihom です。

f:id:cevid_cpp:20161031193103j:plain

SwiftUI を使って REST API 通信をしてアプリを作っていく上で、一通りまとまってる方法がなかなか見つからなかったので、現在の実装をまとめておく。

Combine を使った定義

Combine はあくまで 非同期イベントをハンドリングするフレームワーク であるだけなので、HTTP リクエストの詳細までは実装されていない。以下に3つの役割がある。

  • Publisher: 値を送信
  • Subscriber: 値を受信。値の型が一致している必要がある。
  • Operator: Publisher と Subscriber の間に入り、値を変更する処理をする。

以下、実際にコードを掲示するが、記事としてだいぶシンプルに書いていることだけ、ご了承いただければ幸いだ。

Publisher

import Foundation
import Combine

protocol APIServiceType {
    func request<Request>(with request: Request) -> AnyPublisher<Request.Response, APIServiceError> where Request: APIRequestType
}

final class APIService: APIServiceType {

    private let baseURLString: String
    init(baseURLString: String = "https://api.my-awesome-app.com") {
        self.baseURLString = baseURLString
    }

   func request<Request>(with request: Request) -> AnyPublisher<Request.Response, APIServiceError> where Request: APIRequestType {

    guard let pathURL = URL(string: request.path, relativeTo: URL(string: baseURLString)) else {
        return Fail(error: APIServiceError.invalidURL).eraseToAnyPublisher()
    }

    var urlComponents = URLComponents(url: pathURL, resolvingAgainstBaseURL: true)!
    urlComponents.queryItems = request.queryItems
    
    var request = URLRequest(url: urlComponents.url!)
    request.httpMethod = "POST"
    request.addValue("application/json", forHTTPHeaderField: "Content-Type")
    
    if let token = /* 取得した api token*/ {
        request.addValue(token, forHTTPHeaderField: "X-Mobile-Token")
    }

    let decorder = JSONDecoder()
    decorder.keyDecodingStrategy = .convertFromSnakeCase
    return URLSession.shared.dataTaskPublisher(for: request)
        .map { data, urlResponse in data }
        .mapError { _ in APIServiceError.responseError }
        .decode(type: Request.Response.self, decoder: decorder)
        .mapError(APIServiceError.parseError)
        .receive(on: RunLoop.main)
        .eraseToAnyPublisher()
    }
}

enum APIServiceError: Error {
    case invalidURL
    case responseError
    case parseError(Error)
}

ここで AnyPublisher<Request.Response, APIServiceError> が Combine での Publisher 役となる。うまくいけば リクエストで定義した レスポンスを返し、失敗したら APIServiceError を返す。

Subscriber

JSON が返ってきたときに Swift でのオブジェクトとマッチすれば、その値がセットされた状態で返ってくる。以下の SigninResponse における statusmessage がそれにあたる。

protocol APIRequestType {
    associatedtype Response: Decodable

    var path: String { get }
    var queryItems: [URLQueryItem]? { get }
}

struct SigninRequest: APIRequestType {
    typealias Response = SigninResponse

    var path: String { return "/api/signin" }
    var queryItems: [URLQueryItem]? {
        return [
            .init(name: "email", value: email),
            .init(name: "password", value: password)
        ]
    }

    public let email: String
    public let password: String

    init(email: String, password: String) {
        self.email = email
        self.password = password
    }
}

struct SigninResponse: Decodable {
    let status: String
    let message: String?
}

ここでは API のリクエストで https://api.my-awesome-app.com/api/signin を POST で email, password を送って、その返ってきた JSON で {"status": "OK"}{"status": "NG", message: "Invalid password"} などを返す API がある想定だ。Decodable で定義したのと一致しないと、エラーとなる。? で定義しておけば、Swift 側で あるかないか、任意として定義できる。この時は Swift コード側でオプショナル型として入ってくる。

実装

これでようやくフロント側へ行ける。

import Combine

final class MyViewModel: NSObject, ObservableObject {
    @Published var isLoggedin = false

    private let apiService = APIService()
    private let errorSubject = PassthroughSubject<APIServiceError, Never>()
    private let onSigninSubject = PassthroughSubject<SigninRequest, Never>()
    private var cancellables: [AnyCancellable] = []

    override init() {
        super.init()
        bind()

        self.isLoggedin = isLogin() // 既にログイン中かどうか確認
    }

    private func bind() {
        cancellables += [
            onSigninSubject
                .flatMap { [apiService] (request) in
                    apiService.request(with: SigninRequest(email: request.email, password: request.password))
                        .catch { [weak self] error -> Empty<SigninResponse, Never> in
                            self?.errorSubject.send(error)
                            return .init()
                        }
                }
                .sink(receiveValue: { [weak self] (response) in
                    guard let self = self else { return }
                    if (response.status == "ok") {
                        self.isLoggedin = true // ログイン後の View 表示
                    }
                }),
            errorSubject
                .sink(receiveValue: { [weak self] (error) in
                    guard let self = self else { return }
                    print("api error")
                })
        ]
    }

    // ログインボタン押した際に呼ぶ
    func applyLogin(email: String, password: String) {
        onSigninSubject.send(SigninRequest(email: email, password: password))
    }
}

そしていよいよ最後、SwiftUI で View に埋め込み。

import SwiftUI

struct RootView: View {
    @StateObject var myViewModel = MyViewModel()
    @State private var email = ""
    @State private var password = ""

    var body: some View {
        VStack {
          if !myViewModel.isLoggedin {

            TextField("メールアドレス", text: $email)
            SecureField("パスワード", text: $password)

            Button(action: {
                myViewModel.applyLogin(email: email, password: password)
            }) {
                Text("ログイン")
            }
          } else {
              LoggedinView()
          }
        }
    }
}

終わりに

SwiftUI での ネット通信処理は 最初の大きなハードルの一つだろう。一度問題なく動くように実装できれば、あとは他の API を呼び出しながらアプリの UI を変えていけば良くなる。

SwiftUI の魅力は 上記の中の isLoggedin の更新だろう。 isLoggedin を true にセットするだけで、 View が勝手に切り替わる!最初は頭がぐちゃぐちゃになるけど、慣れてくるとコードをよりシンプルに書ける SwiftUI の魅力を知ることができよう。

本記事は以下の本がベースとなってます。より詳細を知りたい方はぜひ。