はじめに
Swift 5.5からのSwift Concurrencyを使って既存のクロージャベースでコールバックな非同期処理を残しつつasyncメソッドを追加していったらどうなるのか、という試行錯誤みたいなことを書いてみました。
サンプルとしてGitHub APIのリポジトリ検索結果を表示するようにしています。
何か間違いや、もっと良いやり方がある等についてはコメントだったり編集リクエストで是非お願いします
前提
- 今回のコードはViewModelにしてますが、ViewModelを使うということが良いことかどうかは私にはわかりません
-
The Composable Architecture使えばいいとは思うんだけどSwift Concurrencyに慣れたいからとりあえずViewModelにしています
- ほんと適当に目を半開きぐらいの感じでViewModelを作ってる
-
The Composable Architecture使えばいいとは思うんだけどSwift Concurrencyに慣れたいからとりあえずViewModelにしています
- Xcode 13.2.1でiOS 15.2以降をターゲットにしていますが、iOS 15以降のURLSessionのasyncなメソッドを使っていません
- 当分の我々のやることはasyncなメソッドを用意することだろうからクロージャベースのコールバックな非同期処理をasyncに変換してく
- それでもiOS 15.2以上がターゲットなのはSwiftUIとかで便利なのが使いたいから
設計
登場人物紹介
- SwiftUI.App
- MyApp
- 紹介
- Appプロトコル準拠しててDIすべきStateObjectを初期化してる
- 紹介
- MyApp
- SwiftUI.View
- ContentView
- 紹介
- 主人公
- GitHubのリポジトリの一覧をList表示する。タップされても反応しないお年頃
- 紹介
- ContentView
- ViewModel
- 紹介
- もう一人の主人公。キャプ翼で例えると岬くん
- MainActor
- MainActorにしたがそれは必須じゃない
- メソッドに対してMainActor指定してもいいと思うが単純にだるかった
- MainActorにしたがそれは必須じゃない
- 紹介
- WebAPI
- 紹介
- enum
- こいつで囲ってWebAPIの登場人物を列挙してる
- Failure
- 紹介
- WebAPIの失敗を列挙してる。
- 紹介
- Session
- 紹介
- actor
- ViewModelから利用されるだけのためactorである理由がない
- APIのセッションを無理くりモデリングされた
- できることはリクエストをWebにむけてぶん投げてレスポンス返す
- actor
- 紹介
- 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するかどうか
- -> 開発者が完全に実装できてるので実行速度を優先したい
- エラーをthrowするかどうか
- throwする
- withUnsafeThrowingContinuation
- エラーはおきない
- withUnsafeContinuation
- throwする
- エラーをthrowするかどうか
- -> システム側でチェックするようにして欲しい