12
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

SwiftAdvent Calendar 2021

Day 19

Swift Concurrencyを使って既存のクロージャベースな非同期処理を残しつつasyncメソッドを追加していきたい話

Last updated at Posted at 2021-12-20

はじめに

Swift 5.5からのSwift Concurrencyを使って既存のクロージャベースでコールバックな非同期処理を残しつつasyncメソッドを追加していったらどうなるのか、という試行錯誤みたいなことを書いてみました。

サンプルとしてGitHub APIのリポジトリ検索結果を表示するようにしています。

何か間違いや、もっと良いやり方がある等についてはコメントだったり編集リクエストで是非お願いします :pray:

前提

  • 今回のコードはViewModelにしてますが、ViewModelを使うということが良いことかどうかは私にはわかりません
    • The Composable Architecture使えばいいとは思うんだけどSwift Concurrencyに慣れたいからとりあえずViewModelにしています
      • ほんと適当に目を半開きぐらいの感じでViewModelを作ってる
  • Xcode 13.2.1でiOS 15.2以降をターゲットにしていますが、iOS 15以降のURLSessionのasyncなメソッドを使っていません
    • 当分の我々のやることはasyncなメソッドを用意することだろうからクロージャベースのコールバックな非同期処理をasyncに変換してく
    • それでもiOS 15.2以上がターゲットなのはSwiftUIとかで便利なのが使いたいから

設計

登場人物紹介

async.001.png

  • SwiftUI.App
    • MyApp
      • 紹介
        • Appプロトコル準拠しててDIすべきStateObjectを初期化してる
  • SwiftUI.View
    • ContentView
      • 紹介
        • 主人公
        • GitHubのリポジトリの一覧をList表示する。タップされても反応しないお年頃
  • ViewModel
    • 紹介
      • もう一人の主人公。キャプ翼で例えると岬くん
      • MainActor
        • MainActorにしたがそれは必須じゃない
          • メソッドに対してMainActor指定してもいいと思うが単純にだるかった
  • WebAPI
    • 紹介
      • enum
      • こいつで囲ってWebAPIの登場人物を列挙してる
    • Failure
      • 紹介
        • WebAPIの失敗を列挙してる。
    • Session
      • 紹介
        • actor
          • ViewModelから利用されるだけのためactorである理由がない
        • APIのセッションを無理くりモデリングされた
        • できることはリクエストをWebにむけてぶん投げてレスポンス返す
    • GitHubRepositorySearchRequest
      • 紹介
        • リポジトリ検索のリクエストに必要な情報を表現するために無理くりモデリングされた
        • リクエストというプロトコルを作って抽象化した上で実装しようとしたがだるくなった
    • GitHubRepositorySearchResponse
      • 紹介
        • リポジトリ検索のレスポンスの情報を表現するために無理くりモデリングされた
          • [GitHubRepositoryEntity]をもつだけ
    • GitHubRepositoryEntity
      • 紹介
        • リポジトリの情報
        • Identifiableに準拠していることでSwiftUI.Viewから扱いやすいと評判

非同期処理async置き換え

  • Taskによってキャンセルされた場合の処理が書きたい
    • withTaskCancellationHandler
  • システムでasyncとして正しいかをチェックしつつエラーも含みたい
    • withCheckedThrowingContinuation

妥協するところ

  • APIごとにReqeust型を作ってそいつらを抽象化するプロトコルを作る
    • APIひとつなので今回はやらない

コード

import SwiftUI

@main
struct MyApp: App {
    @StateObject private var viewModel = ViewModel()

    var body: some Scene {
        WindowGroup {
            ContentView()
                .environmentObject(viewModel)
        }
    }
}

struct ContentView: View {
    @EnvironmentObject private var viewModel: ViewModel

    var body: some View {
        NavigationView {
            VStack(alignment: .center, spacing: 0) {
                List(viewModel.items) { repository in
                    NavigationLink {
                        EmptyView()
                    } label: {
                        VStack(alignment: .leading) {
                            Text(repository.name)
                                .font(.body)
                            HStack {
                                Image(systemName: "star")
                                Text("\(repository.stargazersCount?.description ?? "0")")
                            }
                            .font(.caption)
                        }
                    }
                }

                Divider()

                HStack {
                    TextField("Search name here...", text: $viewModel.word)
                        .textFieldStyle(RoundedBorderTextFieldStyle())
                        .onSubmit {
                            viewModel.fetchRepositories()
                        }

                    Button("Search") {
                        viewModel.fetchRepositories()
                    }

                    Button("Cancel") {
                        viewModel.cancel()
                    }
                }
                .padding(10)
            }
            .navigationTitle(viewModel.status.rawValue)
        }
    }
}

// ViewModelを @MainActor にするかどうかは悩ましい。
// 短所としてはdeinit時にcancelをawaitしないといけない。それが嫌な感じ。
@MainActor
class ViewModel: ObservableObject {
    enum State: String {
        case initialized
        case cancelled
        case failed
        case searching
        case completed
    }

    // Publishedの必要はないが、Xcodeコンソールで下記のWarningが出てしまう。
    // "Binding<String> action tried to update multiple times per frame."
    // ユーザが入力してるんでそれが描画されるのは分かりきってて不必要なWarningだとは思う。
    @Published var word: String = ""

    @Published private(set) var items: [WebAPI.GitHubRepositoryEntity] = []
    @Published private(set) var status = State.initialized

    private let session: WebAPI.Session
    private var handler: Task<Void, Never>?

    init(session: WebAPI.Session = WebAPI.Session()) {
        self.session = session
    }

    // deinitはglobalActor指定できないのでMainActorできない
    deinit {
        Task {
            await cancel()
        }
    }

    func fetchRepositories() {
        cancel()
        status = .searching
        handler = Task { 
        // ViewModelをMainActor指定しているのでこのTask.init内もMainActorの処理なのか
            // それともデフォルトがMainなのかわからないな.
            do {
                let response = try await session.perform(WebAPI.GitHubRepositorySearchRequest(word: word))
                items = response.items ?? []
                status = .completed
            } catch {
                status = Task.isCancelled ? .cancelled : .failed
            }
        }
    }

    // handlerのキャンセルは今のところどのスレッドからでもいいと思う。
    // もしMainActor func cancel()にしてしまうとdeinit時にTask.detached { await cancel() } になるのもだるい。
    func cancel() {
        handler?.cancel()
    }
}

enum WebAPI {
    enum Failure: Error, LocalizedError {
        case rateLimit
        case unprocessable
        case serviceUnavailable
        case unknown

        var errorDescription: String? {
            switch self {
            case .rateLimit:
                return "API rate limit exceeded."
            case .unprocessable:
                return "Validation Error"
            case .serviceUnavailable:
                return "Service Unavailable"
            case .unknown:
                return "unknown error"
            }
        }
    }

    // actorである必要性はないのでactorにするのをやめた。
    class Session {
        private var task: URLSessionTask?
        private let urlSession: Foundation.URLSession

        // 基本的にはURLSessionごとに通信の並列実行数を制御できるので、それを加味して入れ替えられるようにしておきたいわけ
        init(urlSession: Foundation.URLSession = URLSession.shared) {
            self.urlSession = urlSession
        }

        func perform(_ request: GitHubRepositorySearchRequest) async throws -> GitHubRepositorySearchResponse {
            // 外部からキャンセルされた際の処理のため
            try await withTaskCancellationHandler {
                // withCheckedThrowingContinuationにするのは、効率を無視してもチェックしてくれた方がいいから
                try await withCheckedThrowingContinuation { continuation in
                    perform(request: request) {
                        continuation.resume(with: $0)
                    }
                }
            } onCancel: {
                cancel()
            }
        }

        // レガシーな処理がありこれを変更しないでそのまま使いたいということ
        private func perform(
            request: GitHubRepositorySearchRequest,
            completion: @escaping (Result<GitHubRepositorySearchResponse, Error>
        ) -> ()) {
            cancel()

            let task = urlSession.dataTask(with: request.createURLRequest()) { data, response, error in
                guard error == nil else {
                    completion(.failure(error!))
                    return
                }

                guard let data = data, let response = response as? HTTPURLResponse else {
                    completion(.failure(Failure.unknown))
                    return
                }

                guard response.statusCode != 304 else {
                    // 304のときはキャッシュを使えとあるが、リクエストごとにキャッシュを保持したりすべきかな
                    fatalError("304でどうなるかは今は考えない")
                }

                guard response.statusCode == 200 else {
                    completion(.failure(request.error(for: response.statusCode)!))
                    return
                }

                do {
                    let response = try JSONDecoder().decode(
                        GitHubRepositorySearchResponse.self,
                        from: data
                    )
                    completion(.success(response))
                } catch {
                    completion(.failure(error))
                }
            }

            task.resume()
            self.task = task
        }

        func cancel() {
            task?.cancel()
        }
    }
}

extension WebAPI {
    class GitHubRepositorySearchRequest {
        private let host = URL(string: "https://api.github.com")!
        private let path = "/search/repositories"
        private let method = "GET"
        private var params: [String: String] { ["q": word] }

        private let word: String

        init(word: String) {
            self.word = word
        }

        func createURLRequest() -> URLRequest {
            var components = URLComponents(url: host, resolvingAgainstBaseURL: false)!
            components.path = path
            components.queryItems = params.map { URLQueryItem(name: $0.key, value: $0.value) }

            var request = URLRequest(url: components.url!)
            request.httpMethod = method
            return request
        }

        func error(for statusCode: Int) -> Failure? {
            switch statusCode {
            case 403:
                return Failure.rateLimit
            case 422:
                return Failure.unprocessable
            case 503:
                return Failure.serviceUnavailable
            default:
                return nil
            }
        }
    }

    struct GitHubRepositorySearchResponse: Decodable {
        // カラ文字""を投げるとnilの場合がある
        let items: [GitHubRepositoryEntity]?
    }

    struct GitHubRepositoryEntity: Decodable, Identifiable {
        let id: Int
        let name: String
        let htmlURL: URL
        let description: String?
        let stargazersCount: Int?

        enum CodingKeys: String, CodingKey {
            case id
            case name
            case htmlURL = "html_url"
            case description
            case stargazersCount = "stargazers_count"
        }
    }
}

その他

asyncな関数に変換するフローチャート

  • 処理の継続再開が必ず1度であることは?
    • -> システム側でチェックするようにして欲しい
      • エラーをthrowするかどうか
        • -> throwする
          • withCheckedThrowingContinuation
        • -> エラーはおきない
          • withCheckedContinuation
    • -> 開発者が完全に実装できてるので実行速度を優先したい
      • エラーをthrowするかどうか
        • throwする
          • withUnsafeThrowingContinuation
        • エラーはおきない
          • withUnsafeContinuation
12
7
0

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
12
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?