はじめに
"Processing URL Session Data Task Results with Combine"(https://developer.apple.com/documentation/foundation/urlsession/processing_url_session_data_task_results_with_combine)にあたる内容です。APIとの通信処理にCombineを取り入れるイメージを掴むため、サンプルを作ってみました。
サンプルの概要
仕様に関して
TextFieldに検索ワードを入力して検索する(キーボードの「改行」を選択する)と、GitHubのAPIを使ってユーザー画像とユーザー名の情報を取得し、Listに表示します。SwiftUIのプロジェクトで、アーキテクチャはMVVMです。
開発環境に関して
- macOS Big Sur 11.5.2
- Xcode 12.5.1
- Swift 5.4.2
APIクライアントの実装に関して
APIクライアントの実装に関しては、PEAKS(ピークス)|iOSアプリ設計パターン入門のサンプルコード(iOS_architecture_samplecode/GitHub at master · peaks-cc/iOS_architecture_samplecode · GitHub)を一部引用、また、参考にしました。
実装方針に関して
APIクライアントの従来のコードを、以下のようにCombineに置き換えてみました。
- URLSessionDataTask周りの実装をCombineに置き換える。(従来の処理をdataTaskPublisher、tryMap(_:)、decode(type:decoder:)、mapError(_:)を使って置き換える)
- コールバック処理をFutureに置き換える。
また、通信処理以外では以下のようにCombineを取り入れています。
- 検索を実行するイベントにSubjectを使う。
- コールバック処理にFutureを使う。
- 画像データの取得処理でdataTaskPublisherを使う。
全体の実装
APIクライアント
Session.swift
import Foundation import Combine final class Session { private var cancellables = Set<AnyCancellable>() func send<T: Request>(_ request: T) -> Future<T.Response, SessionError> { return Future() { promise in let url = request.baseURL.appendingPathComponent(request.path) guard var componets = URLComponents(url: url, resolvingAgainstBaseURL: true) else { promise(.failure(SessionError.failedToCreateComponents(url))) return } componets.queryItems = request.queryParameters?.compactMap(URLQueryItem.init) guard var urlRequest = componets.url.map({ URLRequest(url: $0) }) else { promise(.failure(SessionError.failedToCreateURL(componets))) return } urlRequest.httpMethod = request.method.rawValue urlRequest.allHTTPHeaderFields = request.headerFields URLSession.shared.dataTaskPublisher(for: urlRequest) .tryMap() { element -> Data in guard let response = element.response as? HTTPURLResponse else { throw SessionError.noResponse } guard 200 ..< 300 ~= response.statusCode else { let message = try? JSONDecoder().decode(SessionError.Message.self, from: element.data) throw SessionError.unacceptableStatusCode(response.statusCode, message) } return element.data } .decode(type: T.Response.self, decoder: JSONDecoder()) .mapError { error -> SessionError in if let error = error as? DecodingError { return SessionError.parserError(error.localizedDescription) } else { // オフラインなどのエラー return SessionError.other(error.localizedDescription) } } .sink(receiveCompletion: { completion in switch completion { case .finished: break case .failure(let error): promise(.failure(error)) } }, receiveValue: { promise(.success($0)) }) .store(in: &self.cancellables) } } } enum SessionError: Error { case failedToCreateComponents(URL) case failedToCreateURL(URLComponents) case noResponse case unacceptableStatusCode(Int, Message?) case parserError(String) case other(String) } extension SessionError { struct Message: Decodable { let documentationURL: URL let message: String private enum CodingKeys: String, CodingKey { case documentationURL = "documentation_url" case message } } }
User.swift
import Foundation struct User: Codable, Identifiable { let id = UUID() let login: String let avatarURL: URL private enum CodingKeys: String, CodingKey { case login case avatarURL = "avatar_url" } init(login: String, avatarURL: URL) { self.login = login self.avatarURL = avatarURL } }
Request.swift
import Foundation protocol Request { associatedtype Response: Decodable var baseURL: URL { get } var method: HttpMethod { get } var path: String { get } var headerFields: [String: String] { get } var queryParameters: [String: String]? { get } } extension Request { var baseURL: URL { return URL(string: "https://api.github.com")! } var headerFields: [String: String] { return ["Accept": "application/json"] } var queryParameters: [String: String]? { return nil } } enum HttpMethod: String { case get = "GET" case post = "POST" }
SearchUsersRequest.swift
import Foundation struct SearchUsersRequest: Request { typealias Response = ItemsResponse<User> let method: HttpMethod = .get let path = "/search/users" var queryParameters: [String: String]? { let params: [String: String] = ["q": query] return params } let query: String init(query: String) { self.query = query } }
ItemsResponse.swift
import Foundation struct ItemsResponse<Item: Decodable>: Decodable { let items: [Item] init(items: [Item]) { self.items = items } }
その他
SearchUserView.swift
import SwiftUI struct SearchUserView: View { @ObservedObject private var viewModel: SearchUserViewModel init(viewModel: SearchUserViewModel) { self.viewModel = viewModel } var body: some View { VStack { TextField("Search", text: $viewModel.text, onCommit: { viewModel.searchButtonTapped() }) .padding([.leading, .trailing], 25) List { ForEach(viewModel.users) { user in UserListRowView(user: user) } } .listStyle(InsetGroupedListStyle()) } } } struct SearchUserView_Previews: PreviewProvider { static var previews: some View { SearchUserView(viewModel: SearchUserViewModel(searchUserModel: SearchUserModel())) } }
UserListRowView.swift
import SwiftUI struct UserListRowView: View { let user: User @Environment(\.imageCache) private var cache: ImageCache var body: some View { HStack(spacing: 25) { ImageView(url: user.avatarURL, cache: cache) .clipShape(Circle()) Text(user.login) } .frame(height: 50) } } struct UserListRowView_Previews: PreviewProvider { static var previews: some View { UserListRowView(user: User(login: "", avatarURL: URL(string: "")!)) } }
ImageView.swift
import SwiftUI struct ImageView: View { @ObservedObject private var imageloader: ImageLoader init(url: URL, cache: ImageCache? = nil) { imageloader = ImageLoader(url: url, cache: cache) } var body: some View { ZStack { if let image = imageloader.image { Image(uiImage: image) .resizable() .scaledToFit() } } .onAppear { imageloader.load() } } }
SearchUserViewModel.swift
import Foundation import Combine final class SearchUserViewModel: ObservableObject { @Published var text = "" @Published var users: [User] = [] private lazy var subject = PassthroughSubject<Void, Never>() private var cancellables = Set<AnyCancellable>() private let searchUserModel: SearchUserModelProtocol init(searchUserModel: SearchUserModelProtocol) { self.searchUserModel = searchUserModel subject .sink(receiveValue: { _ in searchUserModel.fetchUser(query: self.text) .receive(on: RunLoop.main) .sink(receiveCompletion: { completion in switch completion { case .finished: break case .failure(let error): print(error) } }, receiveValue: { self.users = $0 }) .store(in: &self.cancellables) }) .store(in: &self.cancellables) } func searchButtonTapped() { subject.send() } }
SearchUserModel.swift
import Foundation import Combine protocol SearchUserModelProtocol { func fetchUser(query: String) -> Future<[User], Error> } final class SearchUserModel: SearchUserModelProtocol { private let session = Session() private var cancellables = Set<AnyCancellable>() func fetchUser(query: String) -> Future<[User], Error> { return Future() { promise in let request = SearchUsersRequest(query: query) self.session.send(request) .sink(receiveCompletion: { completion in switch completion { case .finished: break case .failure(let error): promise(.failure(error)) } }, receiveValue: { response in promise(.success(response.items)) }) .store(in: &self.cancellables) } } }
ImageLoader.swift
import UIKit import Combine final class ImageLoader: ObservableObject { @Published var image: UIImage? private let url: URL private var cache: ImageCache? private var cancellables = Set<AnyCancellable>() init(url: URL, cache: ImageCache? = nil) { self.url = url self.cache = cache } func load() { if let cache = cache?[url as AnyObject] { self.image = cache } else { fetchImage(url: url) .receive(on: RunLoop.main) .sink(receiveCompletion: { completion in switch completion { case .finished: break case .failure(let error): print(error) } }, receiveValue: { self.image = $0 self.addCache($0) }) .store(in: &cancellables) } } private func fetchImage(url: URL) -> Future<UIImage, Error> { return Future() { promise in URLSession.shared.dataTaskPublisher(for: url) .compactMap { UIImage(data: $0.data) } .sink(receiveCompletion: { completion in switch completion { case .finished: break case .failure(let error): promise(.failure(error)) } }, receiveValue: { promise(.success($0)) }) .store(in: &self.cancellables) } } private func addCache(_ image: UIImage?) { image.map { cache?[url as AnyObject] = $0 } } }
ImageCache.swift
import UIKit import SwiftUI protocol ImageCache { var cache: NSCache<AnyObject, UIImage> { get set } subscript(key: AnyObject) -> UIImage? { get set } } struct DefaultImageCache: ImageCache { var cache = NSCache<AnyObject, UIImage>() subscript(key: AnyObject) -> UIImage? { get { cache.object(forKey: key) } set(image) { cache.setObject(image!, forKey: key) } } } struct ImageCacheKey: EnvironmentKey { static let defaultValue: ImageCache = DefaultImageCache() } extension EnvironmentValues { var imageCache: ImageCache { get { self[ImageCacheKey.self] } set(image) { self[ImageCacheKey.self] = image } } }
SampleApp.swift
import SwiftUI @main struct SampleApp: App { var body: some Scene { WindowGroup { SearchUserView(viewModel: SearchUserViewModel(searchUserModel: SearchUserModel())) } } }
おわりに
通信処理に限らず、これまでCombineについて調べてきた内容を極力取り入れたサンプルになりました。ある程度Combineを導入することができたものの、手探り感が強く、まだ修正すべき点があると思っています(特にURLSessionDataTaskにおけるエラー時の処理とSubjectの使い所)。今後実践を積み重ねる中で、より理解を深めていきたいと思います。
参考
-
https://developer.apple.com/documentation/combine/fail/trymap(_:)
-
https://developer.apple.com/documentation/combine/fail/decode(type:decoder:)
-
https://developer.apple.com/documentation/combine/fail/maperror(_:)
-
swift - What is the best way to handle errors in Combine? - Stack Overflow
-
iOS_architecture_samplecode/GitHub at master · peaks-cc/iOS_architecture_samplecode · GitHub