ども、@kimihom です。
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
における status
と message
がそれにあたる。
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 の魅力を知ることができよう。
本記事は以下の本がベースとなってます。より詳細を知りたい方はぜひ。