hachinoBlog

hachinobuのエンジニアライフ

【備忘録】Swift Concurrencyを色々いじってみた

  • 最近SwiftConcurrencyに既存プロジェクトを置き換えたりする中で色々と調べたので備忘録として書き残します。
    • 主にasync/await, Async(Throwing)Stream, Taskについての記事になります。

async/awaitの基本的な使い方

asyncとawaitについて

  • asyncはメソッドが非同期作業を実行することを明確にするメソッド属性です。

    • 例としてAppleが提供しているTask型のstaticメソッドsleepなどがあります。
public static func sleep(nanoseconds duration: UInt64) async throws
  • awaitasyncな非同期メソッドを呼び出す際に使われるキーワードです。  
try await Task.sleep(nanoseconds: UInt64(1 * 1_000_000_000))
  • このようにasyncとawaitは対をなす関係にあります。

直列実行パターン

e.g. API1からUser一覧を取得してAPI2からUserの詳細を取得するケース スクリーンショット_2022-05-18_12_47_51.png

  • 処理は上から順番に実行されます。

    • User一覧の取得が終わってから、その値を用いてUser詳細を取得することができます。

sleepメソッドのfinishがコンソールに出力されるまで3秒かかるサンプル スクリーンショット_2022-05-18_12_50_56.png

  • このように直列実行のためsleep()メソッドの全体の実行時間は3秒かかります。

並列実行

  • 並列実行の方法はasync letwith(Throwing)TaskGroup(of:を用いた書き方があります

async letパターン

e.g. 複数のAPIを叩いてそれぞれの結果を合成したモデルを返すケース スクリーンショット_2022-05-18_12_59_07.png

  • async letの宣言部ではawaitキーワードは不要で、その値を実際に使いたい時にawaitを記述します。

    • また、async letで宣言した瞬間に非同期処理が走ります。
// 非同期処理が宣言とともに走る
async let taskA = fetchA()
async let taskB = fetchB()
// 上記で宣言したtaskA, taskBの結果を用いる場合にawait構文が必要になる
let (resultA, resultB) = try await (taskA, taskB)

let resultC = try await fetchC(a: resultA, b: resultB)
  • 下記sleepメソッドのfinishが出力されるのは並列実行のため2秒です。
func sleep() async throws {
  async let sleep1 = Task.sleep(nanoseconds: UInt64(1 * 1_000_000_000)) // 1秒
  async let sleep2 = Task.sleep(nanoseconds: UInt64(2 * 1_000_000_000)) // 2秒
  print("finish")
}
  • 注意点として下記のような書き方もできますが、これは並列にならず直列実行になってしまいます。
async let (taskA, taskB) = (fetchA(), fetchB())
let (resultA, resultB) = try await (taskA, taskB)
  • また、async letで宣言した変数を使うときにawaitを書きますが、複数回その値を使いたい場合に毎度awaitを書いても非同期処理そのものは宣言した瞬間に一度走るだけなのでawaitを書くたびに非同期処理が走ることはありません。
async let value = fetchA()
// 特にfetchA()の処理が2回走ってしまうことはない
let result1 = fetchResult1(value: await value)
let result2 = fetchResult2(value: await value)
async letな変数を使わずにスコープを抜けた場合
  • あるfunction内で宣言したasync letな変数をtry awaitなどせずにそのfunctionのスコープを抜けた場合にどのような挙動になるでしょうか。

  • 答えはスコープを抜けた時点でまだasync letで宣言した処理が完了していなかった場合は、その処理がキャンセルされます。

func sleepTask() async throws {
    print("sleep start")
    do {
        try await Task.sleep(nanoseconds: 2_000_000_000) // 2秒待つ処理
    } catch {
        print(error.localizedDescription)
    }
    print("sleep finish")
}

func asyncSleep() async throws -> String {
    print("start asyncSleep")
    async let sleep1 = sleepTask()
    async let sleep2 = sleepTask()
    print("end asyncSleep")
    return "finish"
}

// 呼び出しもと
Task {
    do {
        let result = try await asyncSleep()
        print("result: " + result)
    } catch {
        print(error.localizedDescription)
    }
}

コンソールの出力結果

start asyncSleep
end asyncSleep
sleep start
sleep start
The operation couldn’t be completed. (Swift.CancellationError error 1.)
sleep finish
The operation couldn’t be completed. (Swift.CancellationError error 1.)
sleep finish

result: finish
  • なぜこういった挙動になるのかというと、async letで宣言したものは後述する構造化されたTaskとして扱われるからだと思われます。

    • スコープを抜けても処理を中断するのではなく、そのまま処理を続けさせたい場合はasync letではなく、新規でTask {}を作り構造化されていないTaskとして扱う必要があります。

with(Throwing)TaskGroupパターン

e.g. あるAPIの返り値である可変長配列をもとにその数分の並列処理をしたいケース

  • APIからユーザーidの一覧を取得して、そのユーザーidをキーに全ユーザー分の詳細情報を取得したい場合 スクリーンショット_2022-05-18_13_10_24.png

withThrowingTaskGroup(of:returning:body:)

  1. 第一引数にはgroup.addで追加した子タスクの処理の返り値の型を指定します。

    • UserDetail.self
  2. 第二引数にはwithThrowingTaskGroup関数自体が返す戻り値の型です。

    • [UserDetail].selfだが書き方によって省略可能
  3. 第三引数には子タスクを使った並列処理のクロージャを書きます。

    • クロージャにThrowingTaskGroup型の引数(group)が渡ってくるので、このgroupに並列処理を追加していくことになります。
  4. なお、子タスク内の処理でエラーが起きてもその時点ではエラーをthrowしないが、groupに対してreduce, waitForAll(), next()などを呼び出した際にエラーがrethrowされるようになっているので子タスクのエラーハンドリングをしたい場合は注意が必要です。

    • スクリーンショット_2022-05-17_18_42_52.png
  5. 子タスク内で戻り値がVoid型であるAPIなどを叩いていて、特にgroupの処理を待っていないコードだと子タスクのAPIのエラーが伝播されません。

  6. withThrowingTaskGroupクロージャの引数であるgroupにaddメソッドで子タスクを追加して並列処理を走らせることが可能だが、これはgroupに追加された子タスクがそれぞれ並列で処理されるだけなので子タスクの中で直列に書いたものは直列実行されます。

let number = try await withThrowingTaskGroup(of: Int.self) { group in
  [1, 2, 3].forEach { num in
    // 最終的に3つの子タスクが作られる
    // 子タスク内の処理は直列で書かれているので、それぞれの子タスクの完了時間は6秒となる
   // 子タスク同士は並列に動作するので全ての子タスクが完了する時間も6秒
    group.add {
      _ = try await Task.sleep(UInt64(1 * 1_000_000_000))
      _ = try await Task.sleep(UInt64(2 * 1_000_000_000))
      _ = try await Task.sleep(UInt64(3 * 1_000_000_000))
      return num
    }
  }
  return try await group.reduce(into: 0) { result, num in
    result += num // 6
  }
}
  • 上記の子タスクが全て終わる時間を3秒にしたい場合は子タスク内の処理をasync letで記述すれば良いです。
    group.add {
      async let task1 = try await Task.sleep(UInt64(1 * 1_000_000_000))
      async let task2 = try await Task.sleep(UInt64(2 * 1_000_000_000))
      async let task3 = try await Task.sleep(UInt64(3 * 1_000_000_000))
      _ = try await (task1, task2, task3)
      return num
    }
  • なお、ErrorをthrowしないバージョンのwithTaskGroup(of:returning:body:)も存在します。

AsyncStream(AsyncThrowingStream)

  • SwiftではSequenceプロトコルに準拠することでforEach, map, filter, reduceなどの関数や for-in文も使えるようになりますが、これのasync/awaitに対応した非同期バージョンとしてAsyncSequenceプロトコルというものがあります。
@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *)
@rethrows public protocol AsyncSequence {

    /// The type of asynchronous iterator that produces elements of this
    /// asynchronous sequence.
    associatedtype AsyncIterator : AsyncIteratorProtocol

    /// The type of element produced by this asynchronous sequence.
    associatedtype Element where Self.Element == Self.AsyncIterator.Element

    /// Creates the asynchronous iterator that produces elements of this
    /// asynchronous sequence.
    ///
    /// - Returns: An instance of the `AsyncIterator` type used to produce
    /// elements of the asynchronous sequence.
    func makeAsyncIterator() -> Self.AsyncIterator
}
  • 例としてURL型にはlinesというプロパティが生えていて、このプロパティの型はAsyncSequenceプロトコルに準拠したAsyncLineSequenceという型が定義されています。
public var lines: AsyncLineSequence<URL.AsyncBytes> { get }
  • これを使うことで指定したURLからその内容を非同期で1行ずつ取得することが可能になります。
Task {
    let url = URL(string: "https://www.apple.com/jp/")!
    for try await line in url.lines {
        print(line + "🌟")
    }
}
  • 出力
<!DOCTYPE html>🌟
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="ja-JP" lang="ja-JP" prefix="og: http://ogp.me/ns#" class="no-js" data-layout-name="apple-trade-in-event">🌟
<head>🌟
    🌟
<meta charset="utf-8" />🌟
<link rel="canonical" href="https://www.apple.com/jp/" />🌟
    🌟
  • このようにAsyncSequenceプロトコルに準拠することで非同期処理の1つ1つが完了したタイミングで値を取得でき、mapreduceなどを使ってその値を簡単に変換することが可能になります。

  • AsyncStreamを使うとカスタムな型をAsyncSequenceプロトコルに容易に準拠させることが可能です。

スクリーンショット_2022-05-18_13_25_48.png

  • このようにAsyncStreamでラップすることでAsyncSequenceプロトコルに準拠したものを簡単に作ることができる

  • AsyncStreamのinitのクロージャにContinuationという型の引数(continuation)が渡ってくるので、continuationに対してyield(_ value:)もしくはfinish()を流します。

    • yield(_ value:)にはAsyncStreamのジェネリクスで定義した型の値を流します。

    • continuationにErrorを流すことのできるfinish(throwing: )はAsyncThrowingStreamを使うことで可能です。

  • AsyncStreamにfinish() or finish(throwing: )が流れた時点でStreamが終了します。

    • AsyncStreamをfor-loopなどで使っていた側のloop処理を抜けるということです。

    • 逆にいうとfinish()orfinish(throwing: )を流さない場合、呼び出し側のfor-loopが終わらないので注意が必要となります。

    • AsyncStreamを実行しているTaskをキャンセルしてもfor-loopを抜けることができるが、その後にキャンセルをハンドリングして適切なfinish(throwing: )を呼ばないとAsyncStreamを実行しているTask自体が正常終了と見なされてしまいます。(Task章で後述)

  • また、特にAsyncStream内の処理が並列に実行されるわけではありません。 スクリーンショット_2022-05-18_13_30_23.png

  • AsyncStreamを並列に実行したい場合はクロージャ内で並列実行のコードを書けば良いだけです。 スクリーンショット_2022-05-18_13_32_44.png

onTermination

  • AsyncStreamのonTerminationを使うとStreamが何によって終了したのか検知することができます。

    • 主に正常/エラー終了時やキャンセル時などの後処理を記述できます。 スクリーンショット_2022-05-18_13_40_59.png

AsyncStreamはダウンロードプログレスのような処理に最適

  • AsyncSequenceの特徴として非同期処理の1つ1つが完了したタイミングで通知がきて、エラーが起きると以降の処理は通知されません。

  • この特性はダウンロードの完了までにプログレス表示をするようなケースに使えるものです。

  • 例えば既存のAPIでファイルをダウンロードするような下記のFileDownloaderというコードがあるとします。

スクリーンショット_2022-05-18_13_48_11.png

  • この既存APIをAsyncStreamでラップすることで下記のように定義できます。 スクリーンショット_2022-05-18_13_53_01.png

呼び出し側 スクリーンショット_2022-05-18_13_54_29.png

Taskについて

  • Task型
    • @frozen struct Task<Success, Failure> where Success : Sendable, Failure : Error

      • イニシャライザ
        • @discardableResult init(priority: TaskPriority? = nil, operation: @escaping @Sendable () async -> Success)
func createTask() -> Task<Int, Error> {
  Task {
    try await Task.sleep(nanoseconds: UInt64(1 * 1_000_000_000))
    return 1
  }
}
  • ジェネリクスで定義したSuccess, Failure型の値を保持します。

    • クロージャ内でその型を返します。

    • try awaitの箇所でErrorがthrowされた場合、TaskのFailure型の方にそのエラーが代入されます。

当初のTaskの役割やイメージ

  • async付きのメソッドを並行性をサポートしていない同期環境から呼び出そうとしたときに出るエラーで'async' in a function that does not support concurrencyというエラーが出ます。

スクリーンショット 2022-05-16 22.52.09.png

  • これを回避するには同じくasync付きのメソッドから呼び出すかTask {}で囲む必要があります。

    • アプリケーションの根源は同期環境から始まるのでasyncなメソッドを呼び出す際にどこかで必ずTaskを使う必要があります。
  • なので手っ取り早く'async' in a function that does not support concurrencyを回避できるもの・・

    • (^p^)<なんかよく分からないけどTaskで囲んだらなおった・・
    • という程度の認識でした。

スクリーンショット 2022-05-16 22.53.23.png

  • ここからもう一歩理解を進めてみました。

Taskとは何なのか

  • タスクとはプログラムの一部として非同期で実行できる作業の単位であり、すべての非同期コードは何らかのタスクの一部として実行されます。

スクリーンショット_2022-05-17_11_40_08.png

  1. 2つのTaskを作って、そのTask内で非同期のsleepを呼び出すと各々のTaskが並列に実行されます。

  2. メソッド内の順序としては出力の内容の通りでTaskの非同期処理が終わる前にこのメソッドを抜けます。

  3. 各Taskは並列に実行されるので2秒後に最後の非同期処理のfinish sleep 2 secondが出力されます。

  4. Taskは生成後すぐに実行され明示的な開始の必要はありません。

    1. ただしTaskのハンドリングをしないと処理を非同期で投げっぱなしただけになるのであまり使い所はない気がします。 (RxSwiftのdoオペレータのように成功・失敗関係なくとりあえず非同期にログを送っておくみたいな処理には良いかもしれませんが)

Taskのハンドリング

  • では下記のような3つのTaskの非同期処理が完了してから関数の返り値であるInt型を返すようにするにはどうすれば良いでしょうか。

スクリーンショット 2022-05-17 14.12.45.png

  • 出力の通りこの書き方だと関数を抜けて3が出力されてから各々の非同期処理が完了しています。

  • 私が最初に思いついたのは、この関数をasyncにして呼び出し側にawaitを書くことでした。

    1-2. しかしながらこれでは先ほどと同じ結果になってしまいました。

Taskで実行した非同期処理の値を取得できるresultvalueプロパティ
  • 関数を各Taskの非同期処理が終わってからInt型を返すようにしたい場合は、Task型に生えているresultもしくはvalueというプロパティがasyncになっているのでこれを使うことで非同期処理の完了を待つことができます。

    • resultプロパティはResult型になっていてSuccessもしくはErrorの値が取得できます。

    • valueプロパティはジェネリクスで定義したSuccess型の値の取得を試みますがTaskがエラーをスローした場合、このプロパティはそのエラーを伝搬します。

public var result: Result<Success, Failure> { get async }

public var value: Success { get async throws }
  • resultプロパティを使って非同期処理の完了を待ってから関数の返り値を返すようにできる スクリーンショット 2022-05-17 15.16.12.png

  • 出力を見てみると

    1. 全ての非同期Taskが終わってから3が返されるようになっています。

    2. 各Taskが並列ではなく直列で実行されるようになっているのでTask.sleepコードが上から2秒、1秒、3秒の順で終わり全てのTaskの処理が完了するまで合計で6秒かかるようになっています。

  • valueプロパティを用いた場合はErrorがthrowされる可能性があるので下記のようになる スクリーンショット_2022-05-17_15_27_37.png

  • 各Taskを並列に実行した上で完了を待ちたい場合

  • 元々Taskは宣言した瞬間に実行されるので各Taskを変数に保持してタプルで一気にtry awaitしてあげると並列になります。

    • 下記のように関数の完了は3秒になります。 スクリーンショット_2022-05-18_1_17_11.png
  • または、各Taskのresultasync letで宣言して、awaitすることで並列実行になる

    • 関数の完了は3秒になる

スクリーンショット_2022-05-17_15_44_22.png

Taskのエラーハンドリング

  • Taskのresultプロパティには非同期処理の結果が格納されます(Success or Failure)。

  • Task内の非同期処理でErrorがthrowされた場合は下記のようにエラーハンドリングできます。

スクリーンショット 2022-05-17 16.04.35.png

  • valueプロパティを使った呼び出しの場合

スクリーンショット 2022-05-17 16.06.41.png

複数のTaskでエラーが発生した際にどのエラーがハンドリングされるのか

  • 書き方(value, result, try?など)によるが直列と並列のシンプルなパターンでの順番を記載します。

  • 直列の場合

スクリーンショット 2022-05-17 16.57.28.png

  • 各Taskを直列に実行した場合、最初にエラーが発生したTaskのErrorがthrowされ、次のTaskは処理されません。

  • 並列の場合

    • async letを使った場合、直列と同じようにtry awaitを記述した順番でthrowされるエラーが決まる

      • スクリーンショット_2022-05-17_17_48_23.png
    • async letで宣言した処理をtry awaitでタプルで書いた場合、エラーがthrowされた順番ではなく常に左辺のエラーが優先されます。

      • スクリーンショット_2022-05-17_17_34_27.png
    • Taskをタプルで待ち合わせた場合も同じなのでタプルは左辺が優先されるっぽい

      • スクリーンショット_2022-05-18_1_29_54.png
    • withThrowingTaskGroup(of:を使った場合は最初にエラーが発生した方が適用されます。

      • スクリーンショット_2022-05-17_18_13_22.png
    • 全てErrorがthrowされるTaskであっても並列処理の場合は全ての非同期処理が走り続け、各々が完了するまで止まりません。

Taskのキャンセル処理

  • Taskにはcancel()メソッドがありTaskの処理を停止することができます。

    • 標準APIであるTaskのsleepメソッドはスリープ中にcancel()が実行されるとCancellationErrorをthrowするような設計になっています。
public static func sleep(nanoseconds duration: UInt64) async throws
  • このメソッドを使ってキャンセル処理をすると下記のようになります。

スクリーンショット_2022-05-17_19_30_10.png

  • 同じようにネットワークリクエストでpublic func data(from url: URL, delegate: URLSessionTaskDelegate? = nil) async throws -> (Data, URLResponse)を実行中にcancel()を使うとNSURLErrorDomainのcancelledがthrowされているようになっています。

    • ただしタスクをキャンセルしても、その場で即座に処理が停止するわけではありません。

    • タスクのキャンセルはタスクに "キャンセルされた" というフラグを立てるだけで、本当にキャンセル実行するのかどうかは各タスク側で明示的に確認する必要があるみたいです。

    • この仕様を 「協調的なキャンセル」(Cooperative cancellation)というらしいです。

スクリーンショット_2022-05-17_21_43_20.png

  • このようにTaskがキャンセルされた時にどういった処理をするかということを自前の非同期処理に実装することができます。
Taskのキャンセルを検知する
  • 自身の非同期処理(Task)のキャンセルを検知するAPIは下記があります。
public var isCancelled: Bool { get }

public static var isCancelled: Bool { get }

public static func checkCancellation() throws

public func withTaskCancellationHandler<T>(operation: () async throws -> T, 
                                           onCancel handler: @Sendable () -> Void) async rethrows -> T

public func withTaskCancellationHandler<T>(handler: @Sendable () -> Void, 
                                           operation: () async throws -> T) async rethrows -> T
  • 例えばあるファイルをネットワークからダウンロードして、そのデータをさら別の形式に変換する重い処理をするTaskがあったとして、これらのAPIを使ってキャンセルチェックを行うことで不要な処理を減らせるようになります。
static var isCancelled: Bool { get }
  • Taskがキャンセルされたかどうかを返します。

    • staticプロパティはTaskのクロージャ内でのみ自身がキャンセルされたかどうかを判定できます。
  • 下記はファイルダウンロード後にキャンセルチェックをしてキャンセルされていた場合は独自定義のErrorをthrowする場合のコードです。

スクリーンショット_2022-05-17_22_13_13.png

  • これにより不要な重い処理を回避することができます。
public static func checkCancellation() throws
  • Taskがキャンセルされていた場合にCancellationErrorをthrowするメソッドです。

スクリーンショット_2022-05-17_22_10_18.png

public func withTaskCancellationHandler(operation: () async throws -> T, onCancel handler: @Sendable () -> Void) async rethrows -> T
  • これまではキャンセルを自ら定期的にチェックする方法でしたが、withTaskCancellationHandlerを使うことでキャンセルされたタイミングを即座に検知することができます。

    • ただし、キャンセルを即座に検知できるだけでoperation:クロージャに書いた処理は中断されることなく続くので、キャンセルを即座に検知した際に自前で中断できるような書き方をする必要があります。

スクリーンショット_2022-05-17_22_29_06.png

  • public func withTaskCancellationHandler<T>(handler: @Sendable () -> Void, operation: () async throws -> T) async rethrows -> Toperation:onCancel handler:の引数の位置が逆になっただけのものなので割愛

構造化されたTask

  • 各々のTaskのライフサイクルやキャンセルが管理されている構造のこと(親子関係)です。

    • 構造化されていることで親Taskのcancel()メソッドを呼び出すと、そのTaskに関連する子Taskのキャンセルも自動で呼び出されます。

    • 構造化されたTaskを作れるのはasync letwith(Throwing)TaskGroupを使った場合のみです。

  • 下記のように親タスクのcancel()を呼び出すことでasync letで内部的に作られた子タスクにもキャンセルが伝播しtry await URLSession.shared.data(from:がキャンセル時にthrowするNSURLErrorDomainのcancelledが出力されていることが確認できます。 スクリーンショット_2022-05-18_0_45_56.png

  • withTaskGroupバージョン スクリーンショット_2022-05-18_0_54_33.png

構造化されていないTask

  • 各々のTaskが独立しておりライフサイクルやキャンセルが共有されていない構造のことです。

    • 関連性がないのであるTaskのcancel()メソッドを呼び出しても他のTaskに通知がいくことはありません。

    • Task型のイニシャライザやTask.detachedなどを用いて作成したものは全て構造化されていないTaskになります。(async letwith(Throwing)TaskGroup以外)

  • Taskの内部で新たにTaskを生成してもインナーのTaskの処理まではキャンセルが伝播しません。 スクリーンショット_2022-05-18_1_07_41.png

  • キャンセルされたTask側で検知してインナーのTaskもキャンセルするようなコードを書くことで対応する必要があります。 スクリーンショット_2022-05-18_1_41_14.png

  • async letを使っても新たなTaskを生成しているような場合は構造化された関係にはなりません。

    • 新たにTaskを生成した時点でその処理は構造化されていないタスクになる スクリーンショット_2022-05-18_14_41_23.png
Async(Throwing)Streamの場合のキャンセル処理
  • AsyncStreamを実行しているTaskがキャンセルされるとStreamが終了する(for-loopを抜ける)が、onTerminationクロージャの中でキャンセルを検知して明示的にstreamにfinish(throwing: )を流さないとエラーが呼び出し元にthrowされません。 スクリーンショット_2022-05-18_11_40_52.png

  • onTerminationでキャンセルを検知してStreamにfinish(throwing: )を流した場合 スクリーンショット_2022-05-18_11_44_16.png

    • これによりTaskのresultもfailureになりStream側でthrowしたエラーになっていることが確認できます。
Async(Throwing)Streamクロージャ内で定義しているTaskについて
  • AsyncStreamのイニシャライザでstreamに値を流すクロージャasyncクロージャになっていないので、非同期処理を呼び出す時にはTaskを使う必要があります。

  • これはAsyncStreamを使う外側のTaskとAsyncStream内の非同期処理のTaskが構造化されていないことを表しています。

AsyncStream内のTask(非同期処理)が処理され続ける例 スクリーンショット_2022-05-18_12_15_55.png

  • AsyncStreamを使う側のtaskをキャンセルしているのでStreamに対してのcontinuation.yield("a")などの値は流れてきませんが、コンソールにはfinish sleep 1などが出続けることからAsyncStream内のTask処理がキャンセルされていないことが分かります。

    • このマズさはダウンロードをキャンセルする場合などを想像してみると明白です。
  • これを回避するにはStreamのonTerminationでキャンセルを検知し、AsyncStream内で定義したTaskのインスタンスに対してキャンセルを実行する必要があります。

AsyncStream内のTaskもキャンセルする例 スクリーンショット_2022-05-18_12_25_56.png

  • innerStreamTaskをキャンセルすることでコンソールにfinish sleep 1などが出なくなりました。

    • innerStreamTaskTask.sleepがキャンセルを検知したことによりCancellationErrorがthrowされたためです。

      • なお、throwされたCancellationErrorは innerStreamTaskのresultに格納されますが、このエラーがAsyncStreamの呼び出し元に伝搬されることはありません。(呼び出し元に伝わるのはコンソールに表示されている通りcontinuation.finish(throwing: MyError.e1)です。)
  • 一連の理解を経て私は、Taskのキャンセルを意識した設計がかなり重要なんだなと感じました。

    • asyncなメソッドはTaskを作らずにasyncな処理を呼び出せるので、意図せず構造化されていないTaskを作ってしまうことを回避できる役割があるのだなとも思いました。

まとめ

いかがでしたでしょうか。 まだまだ調べきれていないことや説明できていない機能が盛りだくさん(Actorについて一切触れておらず)ですが、この備忘録程度の記事が何かのお役に立てれば幸いです。

参考にさせていただいたドキュメントや記事など

Swift Embedded Framework内でCやObjective-Cのライブラリを使う方法

背景

運用しているSwiftアプリでObjective-C製のCocoaPodsライブラリをSwift Embedded Frameworkに閉じて使いたかった。 アプリケーション側で使うならお決まりのBridging Headerファイルを追加して、そこに適宜書いていけば良いのだけれど、Swift Embedded Frameworkで使いたい場合にどうすれば良いかの備忘録。

やり方

例としてObc製のGoogleAnalyticsライブラリをPods経由でインストールしてSwift Embedded Frameworkで使う方法を紹介する。

まず、TrackerというEmbedded Frameworkが切られているとして、Podfileは下記のようにGoogleAnalyticsを使うTrackerターゲットでに記載する。

# Uncomment the next line to define a global platform for your project
# platform :ios, '9.0'

target 'ObjcLibUseEmbededFramework' do
  # Comment the next line if you're not using Swift and don't want to use dynamic frameworks
  use_frameworks!

  # Pods for ObjcLibUseEmbededFramework

  target 'Tracker' do
    pod 'GoogleAnalytics'
  end

  target 'ObjcLibUseEmbededFrameworkTests' do
    inherit! :search_paths
    # Pods for testing
  end

end

まずは、modulemapファイルを定義する。

プロジェクトファイルの同階層に Libraries/GoogleAnalytics/module.modulemap を作成。

f:id:hachinobu:20190104165737p:plain

中身は下記のようにする。

module GoogleAnalytics {
    // Embedded Frameworkで使いたいヘッダーを定義
    header "../../Pods/GoogleAnalytics/Sources/GAI.h"
    header "../../Pods/GoogleAnalytics/Sources/GAIDictionaryBuilder.h"
    header "../../Pods/GoogleAnalytics/Sources/GAIFields.h"
    link "GoogleAnalytics"
}

まず、module GoogleAnalytics {GoogleAnalytics部分がEmbedded Frameworkでインポートするモジュールになる。

header hogehoge..の部分は先で定義したGoogleAnalyticsモジュールに含める機能のヘッダーを指定する。

この場合、GoogleAnalyticsモジュールはGoogleAnalyticsGAI.h,GAIDictionaryBuilder.h,GAIFields.hを含むことになる。

linkはリンカ処理の時に追加されるモジュールを指定する。

ここに書かない場合は手動でGoogleAnalyticsのStaticLibraryを手動でリンクする必要がある。

手動でやる場合は、

[Build Phases]-[Link Binary With Libraries]に{$SRCROOT}/Pods/GoogleAnalytics/Libraries/libGoogleAnalytics.aを指定する

f:id:hachinobu:20190104170050p:plain

module.modulemapを生成したら次に、Embedded FrameworkのTargetの[Build Settings]-[SWIFT_INCLUDE_PATHS]に、module.modulemapのあるフォルダパス"${$SRCROOT}/Libraries"を指定する。

f:id:hachinobu:20190104170355p:plain

これでEmbedded Framework内で先にmodulemapで定義したモジュール名でインポートできるようになる。

import GoogleAnalytics

f:id:hachinobu:20190104170543p:plain

f:id:hachinobu:20190104170609p:plain

linkerエラーになった場合は、ターゲットの[Build Phases] - [Link Binary With Libraries]に必要なものを追加していく。 GoogleAnalyticsの場合は、自身のStaticLibraryやPodSpecファイルのframeworksやlibrariesを見て追加していく。

https://github.com/CocoaPods/Specs/blob/master/Specs/4/9/c/GoogleAnalytics/3.17.0/GoogleAnalytics.podspec.json#L33

遷移中にNavigationBarの設定をアニメーションさせて綺麗に見せる

背景

PushやModal遷移の時に呼び出し元ViewControllerと呼び出し先ViewControllerでNavigationBarやボタンの色が違う時に、その遷移の進捗に応じて設定の色などをアニメーションで変えて綺麗に見せたいかった

方法

UIViewControllerTransitionCoordinatorのメソッドである

func animate(alongsideTransition animation: ((UIViewControllerTransitionCoordinatorContext) -> Void)?, 
  completion: ((UIViewControllerTransitionCoordinatorContext) -> Void)? = nil) -> Bool

を使えば良い。

UIViewControllerTransitionCoordinatorはUIViewControllerのプロパティとして定義されている。

例えばNavigationBarの設定が違うMainViewControllerとDetailViewControllerあるとする

f:id:hachinobu:20180721212023p:plainf:id:hachinobu:20180721212034p:plain
遷移元MainViewControllerと遷移先DetailViewController

設定は呼び出し元のMainViewController.swifが

    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        self.navigationController?.navigationBar.barTintColor = .blue
        self.navigationController?.navigationBar.tintColor = .white
    }

遷移先のDetailViewController.swif

    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        transitionCoordinator?.animate(alongsideTransition: { _ in
            self.navigationController?.navigationBar.tintColor = .red
            self.navigationController?.navigationBar.barTintColor = .yellow
        }, completion: nil)
    }

といったようにやると、Pushなどの遷移中に遷移の比率に応じてアニメーションで色が変わっていくの良い。 シュミレーターの[Debug]-[Slow Animations]をONにして確かめると一目瞭然。

エッジスワイプバックのキャンセル対応

エッジスワイプで前の画面に戻ろうとしてキャンセルする操作をすると、ライフサイクル的に呼び出し元の画面のviewWillAppearが呼ばれ、現在表示されている遷移先画面はviewDidAppearが呼ばれるので、この実装だとエッジスワイプバックのキャンセルをすると、遷移元であるMainViewControllerの設定がDetailViewControllerに反映されてしまう。

f:id:hachinobu:20180721213919p:plainf:id:hachinobu:20180721213930p:plain
スワイプバックによる設定崩れ

なのでDetailViewControllerでtransitionCoordinator?.animateメソッドのcompletionブロックで、この画面の完成形となる色設定などを書いてあげておくと、違う画面の設定になってしまうということを防げる。

    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        transitionCoordinator?.animate(alongsideTransition: { _ in
            self.navigationController?.navigationBar.tintColor = .red
            self.navigationController?.navigationBar.barTintColor = .yellow
        }, completion: { _ in
            self.navigationController?.navigationBar.tintColor = .red
            self.navigationController?.navigationBar.barTintColor = .yellow
        })
    }

そのサイトがATS対応しているのかを調べる方法

背景

あれ? WebViewで外部サイトが開かないんですけど・・もしかしてみたいな状況

やり方

nscurlコマンドというものを知った

これを使うとそのドメインのサイトがATSに対応しているか、iOS側でどういった設定をすれば通るようになるのかを教えてくれる。

nscurl --ats-diagnostics --verbose https://www.google.co.jp/

みたいに叩くと

Starting ATS Diagnostics

Configuring ATS Info.plist keys and displaying the result of HTTPS loads to https://www.google.co.jp/.
A test will "PASS" if URLSession:task:didCompleteWithError: returns a nil error.
================================================================================

Default ATS Secure Connection
---
ATS Default Connection
ATS Dictionary:
{
}
Result : PASS
---

================================================================================

Allowing Arbitrary Loads

---
Allow All Loads
ATS Dictionary:
{
    NSAllowsArbitraryLoads = true;
}
Result : PASS
---

================================================================================

Configuring TLS exceptions for www.google.co.jp

---
TLSv1.3
ATS Dictionary:
{
    NSExceptionDomains =     {
        "www.google.co.jp" =         {
            NSExceptionMinimumTLSVersion = "TLSv1.3";
        };
    };
}
2018-07-18 22:57:15.859 nscurl[36979:1099485] NSURLSession/NSURLConnection HTTP load failed (kCFStreamErrorDomainSSL, -9800)
Result : FAIL
Error : Error Domain=NSURLErrorDomain Code=-1200 "An SSL error has occurred and a secure connection to the server cannot be made." UserInfo={_kCFStreamErrorCodeKey=-9800, NSLocalizedRecoverySuggestion=Would you like to connect to the server anyway?, NSUnderlyingError=0x7fac10714b10 {Error Domain=kCFErrorDomainCFNetwork Code=-1200 "(null)" UserInfo={_kCFStreamPropertySSLClientCertificateState=0, _kCFNetworkCFStreamSSLErrorOriginalValue=-9800, _kCFStreamErrorDomainKey=3, _kCFStreamErrorCodeKey=-9800}}, NSLocalizedDescription=An SSL error has occurred and a secure connection to the server cannot be made., NSErrorFailingURLKey=https://www.google.co.jp/, NSErrorFailingURLStringKey=https://www.google.co.jp/, _kCFStreamErrorDomainKey=3}
---

---
TLSv1.2
ATS Dictionary:
{
    NSExceptionDomains =     {
        "www.google.co.jp" =         {
            NSExceptionMinimumTLSVersion = "TLSv1.2";
        };
    };
}
Result : PASS
---

---
TLSv1.1
ATS Dictionary:
{
    NSExceptionDomains =     {
        "www.google.co.jp" =         {
            NSExceptionMinimumTLSVersion = "TLSv1.1";
        };
    };
}
Result : PASS
---

---
TLSv1.0
ATS Dictionary:
{
    NSExceptionDomains =     {
        "www.google.co.jp" =         {
            NSExceptionMinimumTLSVersion = "TLSv1.0";
        };
    };
}
Result : PASS
---

================================================================================

Configuring PFS exceptions for www.google.co.jp

---
Disabling Perfect Forward Secrecy
ATS Dictionary:
{
    NSExceptionDomains =     {
        "www.google.co.jp" =         {
            NSExceptionRequiresForwardSecrecy = false;
        };
    };
}
Result : PASS
---

================================================================================

Configuring PFS exceptions and allowing insecure HTTP for www.google.co.jp

---
Disabling Perfect Forward Secrecy and Allowing Insecure HTTP
ATS Dictionary:
{
    NSExceptionDomains =     {
        "www.google.co.jp" =         {
            NSExceptionAllowsInsecureHTTPLoads = true;
            NSExceptionRequiresForwardSecrecy = false;
        };
    };
}
Result : PASS
---

================================================================================

Configuring TLS exceptions with PFS disabled for www.google.co.jp

---
TLSv1.3 with PFS disabled
ATS Dictionary:
{
    NSExceptionDomains =     {
        "www.google.co.jp" =         {
            NSExceptionMinimumTLSVersion = "TLSv1.3";
            NSExceptionRequiresForwardSecrecy = false;
        };
    };
}
2018-07-18 22:57:16.828 nscurl[36979:1099496] NSURLSession/NSURLConnection HTTP load failed (kCFStreamErrorDomainSSL, -9800)
Result : FAIL
Error : Error Domain=NSURLErrorDomain Code=-1200 "An SSL error has occurred and a secure connection to the server cannot be made." UserInfo={_kCFStreamErrorCodeKey=-9800, NSLocalizedRecoverySuggestion=Would you like to connect to the server anyway?, NSUnderlyingError=0x7fac1071b870 {Error Domain=kCFErrorDomainCFNetwork Code=-1200 "(null)" UserInfo={_kCFStreamPropertySSLClientCertificateState=0, _kCFNetworkCFStreamSSLErrorOriginalValue=-9800, _kCFStreamErrorDomainKey=3, _kCFStreamErrorCodeKey=-9800}}, NSLocalizedDescription=An SSL error has occurred and a secure connection to the server cannot be made., NSErrorFailingURLKey=https://www.google.co.jp/, NSErrorFailingURLStringKey=https://www.google.co.jp/, _kCFStreamErrorDomainKey=3}
---

---
TLSv1.2 with PFS disabled
ATS Dictionary:
{
    NSExceptionDomains =     {
        "www.google.co.jp" =         {
            NSExceptionMinimumTLSVersion = "TLSv1.2";
            NSExceptionRequiresForwardSecrecy = false;
        };
    };
}
Result : PASS
---

---
TLSv1.1 with PFS disabled
ATS Dictionary:
{
    NSExceptionDomains =     {
        "www.google.co.jp" =         {
            NSExceptionMinimumTLSVersion = "TLSv1.1";
            NSExceptionRequiresForwardSecrecy = false;
        };
    };
}
Result : PASS
---

---
TLSv1.0 with PFS disabled
ATS Dictionary:
{
    NSExceptionDomains =     {
        "www.google.co.jp" =         {
            NSExceptionMinimumTLSVersion = "TLSv1.0";
            NSExceptionRequiresForwardSecrecy = false;
        };
    };
}
Result : PASS
---

================================================================================

Configuring TLS exceptions with PFS disabled and insecure HTTP allowed for www.google.co.jp

---
TLSv1.3 with PFS disabled and insecure HTTP allowed
ATS Dictionary:
{
    NSExceptionDomains =     {
        "www.google.co.jp" =         {
            NSExceptionAllowsInsecureHTTPLoads = true;
            NSExceptionMinimumTLSVersion = "TLSv1.3";
            NSExceptionRequiresForwardSecrecy = false;
        };
    };
}
2018-07-18 22:57:17.450 nscurl[36979:1099485] NSURLSession/NSURLConnection HTTP load failed (kCFStreamErrorDomainSSL, -9800)
Result : FAIL
Error : Error Domain=NSURLErrorDomain Code=-1200 "An SSL error has occurred and a secure connection to the server cannot be made." UserInfo={_kCFStreamErrorCodeKey=-9800, NSLocalizedRecoverySuggestion=Would you like to connect to the server anyway?, NSUnderlyingError=0x7fac10720c30 {Error Domain=kCFErrorDomainCFNetwork Code=-1200 "(null)" UserInfo={_kCFStreamPropertySSLClientCertificateState=0, _kCFNetworkCFStreamSSLErrorOriginalValue=-9800, _kCFStreamErrorDomainKey=3, _kCFStreamErrorCodeKey=-9800}}, NSLocalizedDescription=An SSL error has occurred and a secure connection to the server cannot be made., NSErrorFailingURLKey=https://www.google.co.jp/, NSErrorFailingURLStringKey=https://www.google.co.jp/, _kCFStreamErrorDomainKey=3}
---

---
TLSv1.2 with PFS disabled and insecure HTTP allowed
ATS Dictionary:
{
    NSExceptionDomains =     {
        "www.google.co.jp" =         {
            NSExceptionAllowsInsecureHTTPLoads = true;
            NSExceptionMinimumTLSVersion = "TLSv1.2";
            NSExceptionRequiresForwardSecrecy = false;
        };
    };
}
Result : PASS
---

---
TLSv1.1 with PFS disabled and insecure HTTP allowed
ATS Dictionary:
{
    NSExceptionDomains =     {
        "www.google.co.jp" =         {
            NSExceptionAllowsInsecureHTTPLoads = true;
            NSExceptionMinimumTLSVersion = "TLSv1.1";
            NSExceptionRequiresForwardSecrecy = false;
        };
    };
}
Result : PASS
---

---
TLSv1.0 with PFS disabled and insecure HTTP allowed
ATS Dictionary:
{
    NSExceptionDomains =     {
        "www.google.co.jp" =         {
            NSExceptionAllowsInsecureHTTPLoads = true;
            NSExceptionMinimumTLSVersion = "TLSv1.0";
            NSExceptionRequiresForwardSecrecy = false;
        };
    };
}
Result : PASS
---

================================================================================

といった感じで、TLSのver.いくつとか、暗号化スイートの設定とか、どういった設定なら通るよというのが分かってめっちゃ便利。

各設定の意味とかは公式ドキュメント

developer.apple.com

参考

教えてもらった

blog.kishikawakatsumi.com

管理者権限ないのでHomebrewでrbenvを入れたけどrbenv execを省略できなくてハマったメモ

背景

管理者権限ない状態でgem install bundler叩いてもPermissionErrorになるのは周知の通り なのでrbenv経由でruby入れるとユーザー領域にgemをインストールできるよってことで下記をやってみたが、コンソール再起動すると、bundleコマンドを叩いてもコマンドないよーって言われる始末。

管理者権限なしでgemをインストールする

もちろん rbenv exec bundle exec hogehogeとかは大丈夫。

解決方法

上記の参考にした記事で

if which rbenv> /dev/null; then eval "$(rbenv init -)"; fi

source ~/.bash_profile

とかコマンド叩いてるので良い感じに.bash_profileが更新されると思ってたけどされてなかった。

試しに rbenv init コマンド叩いてみると下記が出力

# Load rbenv automatically by appending
# the following to ~/.bash_profile:

eval "$(rbenv init -)"

ということで.bash_profileeval "$(rbenv init -)"を追記してsource ~/.bash_profile叩いてbundle コマンドを実行してみると無事に動きました (もちろんコンソール再起動しても)

このあたりのスキルなさすぎてハマりすぎて引いた・・

WKWebViewのscrollの高さを動的に読み込む方法

背景

セルにWKWebView引いてコンテンツを読み込ませて、読み込み後のスクロールの高さを取得して、セルの高さにしたかったけど色々つまったので備忘録

解決方法

StackOverFlowに載ってる方法でほぼ問題なし

How to determine the content size of a WKWebView?

だけど、1点だけうまくいかなかったのが、高さを取得する時に navigationDelegatefunc webView(_ webView: WKWebView, didFinish navigation: WKNavigation!)メソッド内で高さの取得を

webView.evaluateJavaScript("document.body.offsetHeight", completionHandler: { (height, error) in
                            
})

で取得すると、正しい高さよりも激しく大きな数値が返ってきてしまい、スクロールすると余白だらけになってしまった。

なので、

webView.scrollView.contentSize.height

で取得するようにしたら正しいのが取れた

func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
        
        webView.evaluateJavaScript("document.readyState", completionHandler: { (complete, error) in
            if complete != nil {
                print(webView.scrollView.contentSize) //正しいの取れる
            }
        })
}

RuntimeError - [Xcodeproj] Unknown object version. を解消する

背景

Xcode 9.3 betaを使ってPodfile作って、いつものように pod install を叩くと RuntimeError - [Xcodeproj] Unknown object version. とエラーが出た。

色々調べてみるとCocoaPodsのリポジトリにissue立ってて解決されていた (#7458)https://github.com/CocoaPods/CocoaPods/issues/7458

解決方法

Rubyから*.xcodeprojファイルをいじることができるgemであるxcodeprojをインストールすれば良い。

[sudo] gem install xcodeproj

これをインストール後に pod install を叩いたらxcodeprojファイルをゴニョゴニョ最適なフォーマットにしてくれてPods経由のライブラリも正常にインストールできました