Swift・iOS

Swiftを中心に学んだことを記録に残すブログです。技術に関係ない記事もたまに書いています。

【Combine】APIとの通信処理にCombineを取り入れる(dataTaskPublisher)

 

はじめに

"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に検索ワードを入力して検索する(キーボードの「改行」を選択する)と、GitHubAPIを使ってユーザー画像とユーザー名の情報を取得し、Listに表示します。SwiftUIのプロジェクトで、アーキテクチャはMVVMです。

 

開発環境に関して

 

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の使い所)。今後実践を積み重ねる中で、より理解を深めていきたいと思います。

 

参考