今回は、前回に予定していた個人的にお気に入りな技術ブログ「その Swift コード、こう書き換えてみないか」の余興的な NotificationCenter
を Swift Concurrency で扱う話題をもう少し見ていくことにしますね。どうぞよろしくお願いします。
——————————————————————————————————————————————————— 熊谷さんのやさしい Swift 勉強会 #284
00:00 開始 00:54 NotificationCenter を Swift Concurrency で使う 02:19 Task のキャンセルまわりを確認する 04:46 NotificationCenter.notifications のキャンセル時の振る舞い 10:31 協調的キャンセル 11:58 現在のタスクを知る方法について 14:53 どこからでもカレントを取れるか、カレントを引数で渡していくか 19:45 DI 的な観点では嫌われる手法? 20:33 どの手法が良いかは状況に依りそう 22:15 Task のキャンセルについてのまとめ 23:25 AsyncThrowingStream でエラーからの復帰はできる? 24:41 Sequence への再突入は可能? 27:23 ここからしばらく迷走⋯ 28:36 エラーで中断されても監視を再開したいとき 36:33 AsyncThrowingStream は、イテレーターを作り直しが無難かもしれない 38:44 イテレーターを作り直すこと自体は適切そう 43:09 ややこしいコードをどこに閉じ込めておくか 48:22 止まっちゃったら再スタート — は、よくある方法 50:26 今回の所感とクロージング ———————————————————————————————————————————————————
Transcription & Summarize : 熊谷さんのやさしい Swift 勉強会 #284
それでは始めていきますね。今日はまず元となったブログを表示するところから始めましょう。Swiftの方に書き換えてみましょうかね。えーと、出てきた出てきた。あれ?これですね、このブログを長いこと気に入って見ておりますが、これの中の何だっけ、Notification Centerのお話です。一通りざっくり見終えましたが、もっと見ておきたいと思いました。Notification CenterをSwift Concurrencyで扱うという話をしようと思います。
前回見たのが、Notifications
というプロパティがあるよっていう話でした。iOSではNotification Centerのデフォルトのインスタンスが使いやすくなっています。例えば、Notificationの名前としてNotification.WillHideNotification
などとし、for await
を使ってシーケンスを処理します。このようにコンカレントに対応したNotification Centerがあるよという話を前回しました。
ここで気になるのがキャンセル周りです。自分がNotificationではなくSwift Concurrencyでタスクをキャンセルするっていうことがあまりなくて、やり方が気になるところです。せっかくなので、この機会に調べてみようと思います。状況によってはタスクをキャンセルするかしないかがあると思うんですけど、例えば、Notificationを使わない選択をすることが前にありました。
例えばコントローラーにNotificationを監視させたいとき、for await
を無限ループで使うので、どこに置いておく必要があります。一般的にどう使うのか、もし詳しい人がいたら教えてもらえればと思います。Notificationが無限にシーケンスで届くので、エクステンションでNotificationの名前をstaticで定義する操作を行います。
具体的には、以下のコードになります。
extension Notification.Name {
static let myNotification = Notification.Name("MyNotification")
}
これを利用して、for
文でループしながら通知を受け取ります。このコードをコントローラーがインスタンス化されたときに実行します。通知を受け取ったら、その内容を表示します。例えば、次のようになります。
class MyController {
private var task: Task<Void, Never>? = nil
init() {
self.task = Task {
for await notification in NotificationCenter.default.notifications(named: .myNotification) {
print("Received notification: \\(notification)")
}
}
}
deinit {
task?.cancel()
}
}
これでインスタンス化されたコントローラーが待ち受けを始めて、通知を投げれば受け取ることができます。
タスクをキャンセルしたいときはtask
をオプショナルにして、コントローラーがdeinit
されるときにキャンセルします。タスクがちゃんとキャンセルされるかどうかを確認します。タスクをキャンセルするために、イニシャライザーでタスクを設定しておけば簡単ですね。
デフォルトではタスクのキャンセルを行う際に、以下のコードを使います。
task?.cancel()
親が解放されるときには、タスクもキャンセルされるということです。このタスクの中で、キャンセル通知が来た場合の対処方法も見てみます。タスクのキャンセルを監視しながら動作させるという感じです。
ここまでで、一度試してみましょう。何か他のタスクを立ち上げてみて、プリント文で処理がキャンセルされるかどうかを確認します。
例えば、こんな感じです:
if Task.isCancelled {
print("Task was cancelled")
return
}
タスクのキャンセルを監視するために、スレッド制御についての専門用語もありましたね。スレッドのループ内でスレッドがキャンセルされたかどうかをプログラマーがチェックする必要があります。Swift Concurrencyでは、Task.isCancelled
を使って確認します。キャンセルされた場合に適切にタスクを終了させるように設計します。これがSwift Concurrency版のキャンセル処理です。
確かに、タスクはよく使われますね。以上のように、Notification CenterとSwift Concurrencyを利用する方法と、タスクのキャンセルについて今日は勉強しました。質問があればどうぞ。 確かに、さまざまなタスクやノーティフィケーションの処理において、キャンセルの把握は重要です。それを渡して制御することができれば良いのですが、親のタスクを渡すといった利点についても話しました。それでも、カレントの方が安全かもしれませんね。そもそも滅茶苦茶なタスクを渡しても、それで制御できれば良いんですが、カスタマイズ可能な方がメリットがあります。
タスクキャンセルを渡すか、自分のタスクに依存するAPIを作る時に、カレントに対して何かを行うのか、それともパラメータとして渡して処理するのか、という観点で考えることが時々あります。例えば、最近作成したSafariのWebエクステンションのケースでは、バックグラウンドで動いているプロセスからメッセージをフォアグラウンドに投げる際に、アクティブなタブに対してアラートを出す必要があります。具体的には、バックグラウンドからブラウザに対して、デバッグプリントとしてアクティブなタブにメッセージを送る処理です。
このときの書き方として、JavaScriptで関数を作り、メッセージを表示させて、タブを指定してメッセージを送るようなコードを記述します。例えば、function logMessage(tab, message) { /* タブにメッセージを表示する処理 */ }
のように、アクティブなタブを受け取る設定にするか、それともデータスペースにアクティブタブを保存して、それに対してアクションを行うか、という選択肢があります。
最近書いたコードでは、ログ関数にタブを渡さなければならないことが煩わしく感じます。例えば、46行目でログを表示するためだけにタブを渡す必要があると、別のファンクションを呼び出す時に繰り返しタブを渡すことになり、関数が煩雑になります。ここで、タブをグローバルに登録するか、各関数呼び出し時に渡すかの選択に迷うという話になります。
一般的に見て、デバッグをしやすい観点から、ボットのインスタンスを渡すとカスタマイズが難しくなるためデバッグがしにくくなる可能性があります。そのため、開発者が楽をするためには、適切な判断が求められます。 依存するタスクのカレントに依存する場合、これをここから渡すようにすることで、デバッグ中にもしカレントのタスクが問題を引き起こす場合でも対応可能です。そうすることで、カレントのタスクがキャンセルされるかどうかをプログラマーが条件分岐で判定する必要があり、それが全体的な設計としての約束になっているのかもしれないですね。要するに、そのタスクはカレントタスクに依存しているということです。
たとえば、カレントのタスクが問題を引き起こす場合でも、別のタスク上でこのプロパティを見ればデバッグも可能です。そして、外的要因を考慮すると、パラメータに渡さなくても別の方法で解決策が見つかるかもしれません。一般的な話として、例えばペアレントビューとかペアレントビューコントローラーのように、インスタンス初期化などで設定するものもあるので、どこかから確かめられればデバッグもできるものです。
そして、タスクキャンセルを発行すれば、ノーティフィケーションズも速やかにキャンセルされるので、無限ループが延々と続くことはなくなります。これは非常に重要です。もちろんここでタスクキャンセルが行われない場合もあると思いますが、その場合には前もってタスクをプロパティに持たせておき、そのタスクを適切に始末さえすれば、どんどん使っていけます。
少し関連のない話ですが、例えば async
シーケンスのようなものでエラー処理を考えるとき、現在のタスクがキャンセルされる場合などについても考える必要があります。エラーがスローされると await
が終わってしまうケースなどをどう処理するかについてです。昔、ループにラベルを付けるという話がありましたが、これが使えるのではないかと思います。実際に試してみたいと思います。
まずは try await
でエラーが出る箇所を想定し、そこをキャッチして、再度ループに戻るという考え方を試してみます。
do {
try await someAsyncFunction()
} catch {
print("Error occurred: \\(error)")
// ループにラベルを付けてエラー後に再試行する例
loopLabel: for await value in someAsyncSequence {
do {
try await process(value)
} catch {
print("Retrying due to error")
continue loopLabel
}
}
}
このコードを動かしてみると、エラーが発生した場合にラベル付きループに戻って再度処理を試みることができます。ただし、この方法でも毎回ループを再構築しているわけなので、ループ全体が再スタートされ、リトライ機能としては適切ですが、実際にはもう少し複雑なエラーハンドリングが必要かもしれません。
以上のように、エラー処理やタスク管理を適切に行うための考慮すべき点が多々ありますが、慎重に設計することでより堅牢なシステムを構築できます。 これぐらいここでいいんじゃないのどうなんだろう、ちょっとやってみます。
あ、これでバグが出てきましたね。そうか、バグを中に入れるんだ。for
ループの中にバグを入れて、それでフローに対してキャッチする。でもそうじゃなくて、await
みたいな非同期処理になるので、ループと一緒じゃないとダメなんですよね。await
でもフローすれば外側をキャッチしたい。そうするとやっぱりさっきのコードになってくるんですね。なるほど。
実質、スローイングストリームがワクワクする感じで、いろんなイベントがランダムに飛んでくるみたいな。そうしようと思ってワクワクするんですけど、エラーがスローされた時に処理が終わってしまうので、エラーは飛ばしたいし、アプリの生存期間中にずっと監視しておきたいんです。
デバイスのノーティフィケーションがエラーをスローされるみたいな、そういう場合にアプリの生存期間中、ずっと監視をしているけど、間にエラーが発生する可能性もあると。ずっと動かし続けたいときに、このバグにラベルを付けるという方法が紹介されて、それが生きた初めてのパターンかと思いました。なるほど、トータルで見れば思ったように動く気はします。ただ、イテレーターがここで毎回新たにエラーが出た時には作り直されるのが気になりますね。それで問題がなければいいんですけど、今試しているアプリには問題ないという感じです。
逆に、ストリーム相当のものがコンピューテッドプロパティーなんですよね。なので、何度も非同期ストリームを作り直しているんですよね。確かにビルドモデル相当のものを経由してストリームを取るんですけど、非同期ストリームの仕様として複数のタスクで使用することが可能です。そのため、コンピューテッドプロパティーで都度生成でも問題がないという結論に至りました。イテレーターを外で作っておいて、それを回していくというイメージですね。while
ループを使って、一つのタスク内でイテレーターを使い続けたいんです。
私の考えが違うのは、無限にイベントがランダムで飛んでくるということ。なので、非同期ストリームのyield
でonNext
みたいな感じでイベントが飛んでくるみたいなイメージですよね。もともと集まっているものをイテレーターするという感じではないです。このサンプルがモニター系になって、自分で作ってもらったクイックモニターが無くなったらyield
でクイックを起こす感じです。待ってても集まってなくても同じような気がしますが、こういう感じでモニターがあって、ハンドラーで何か飛んできたらyield
でクイックを起こす。コアビートの仕様として、自信を持つために来るみたいな。
なので、私のアプリの制作期間中、このコアビートはずっと存在し続けるイメージです。エラーをスローしなければ、ユーザーエクスペリエンスがストップしない限り、終わらないですね。エラーがスローされた場合、自動的に処理が終わってしまいますが、終了した後にまたこのコアビートでユーザーエクスペリエンスを待ちたい。どうするのが一番良いのか考えました。
とりあえず、今書いた54行目からのコードと、59行目からのコードのどちらも動き方としては同じです。エラーが発生した時もしなかった時も、エラーを再生することなく回していけるはずですので、これでタスクを包むことでエラーハンドリングしてから続投するという形で。これなら心配が全くないかなという気がします。キャンセル判定は少しだけ取りますね。
これってイテレーターの内部的なものは変わらないんですかね?キャンセル状態って戻ってきた時にどうなんだろう。エラーを発生した時に、ストリーム次第なんじゃないですかね。エラーが発生した時にオブザーバーをやめるタイプのイテレーターなのか、キャンセルが発行されるまではオブザーバーし続けるか。ずっとし続けるんじゃないかなという気もしますが、どうなんでしょうか。
非同期ストリームに関しては、yield
でエラーを渡す感じなんですよね。 同じインスタンスを使い回す際に、内部的にどうなるのかが気になりますよね。この場合、ストリームは共通リソースをファイナライズしてしまうので、新たなインスタンスが生成されるわけではありません。この点を理解して使うことで、問題が発生するリスクを避けることができます。
私の場合、これは計算プロパティにしていますので、呼び出しごとに新たなインスタンスを作り直しているんです。こうすることで、ストリームが再生成されないようにしています。これも利用者が適切に理解すれば、安全に使用できるでしょう。
一回作り直す計算プロパティで非同期ストリームを作るという方法が妥当かどうかは疑問が残ります。最初は良くないと感じましたが、後に複数のタスクでリセットされる場面では、計算プロパティで新たに作り直す方が良いとされています。この方法で納得できる部分もあります。
値を一度作り直すインデクサを使い回すことは難しいです。特にタスクが別々の場合、インデクサを共通化するのは無理があります。ですから、インデクサを作り直すという方法も計算プロパティで使うことがあり得ます。
ストリームがどういう状況で破棄されるかという話のときに、再度新たなストリームを作り直すという手法か、もしくは try? await
という形でエラー発生時に再生成するかが考えられます。これにより、エラー時にはストリームがリセットされます。このような方法は、エラー発生時にもストリームを再利用できるようにするための手続きです。
エクステンションで処理を隠蔽するというのも一つの方法です。エラーの際にどうするかをエクステンション側で定義することで、内部での隠蔽が可能になります。インベントで必要最低限のスコープに取り入れておくことで、読む側にも分かりやすくなります。
ちなみに、フォーアウェイトインでエクステンションを使う方法についても考えましたが、これならインデクサを再設定する表現も可能でしょう。エクステンションで非同期ストリームをループさせることで、特定のエラー処理もスムーズに行うことができます。 要は、さっきの部分でエクステンションを一度作り直すという話があったみたいです。もうそのコードは消しちゃったかもしれませんね。ただ、41秒メソッドがネットケースイコールケースモニターケースという形にできるのか、ということですね。はい、できますが、エクステンションストリームが禁止されていると、多分ヒールドでうまくいかないかもしれません。ただ、エクステンションを活かしておけば、また新たなエーシングストリームが問題を引き起こすことはないと思います。
コンティニュエーションに関しても、古典的で分かりやすいかもしれませんが、それも悪くないかもしれません。状況が分かりやすくなります。あとは、エーシングストリームを独自に工夫して作り、エラーが発生しても終わらないようにすることが大事です。エーシングストリームを作るのが面倒なら、リンクを使えば良いのですが、それも関数を積極的に呼べば良いという話になりますね。
この辺は、Doラベルの分かりにくさよりも、エーシングストリームの分かりにくさのような感じがします。セッションがエラーで止まるために、再度関数を呼び直すのは、悪くない方法かもしれません。無理やり詰め込まなくても良いですね。
セッションがエラーで止まってしまう場合、そのエラーをハンドリングしてもう一度関数を呼び直せば解決します。こうすることで、エクステンションを持ち出してその中に閉じ込めると良いかもしれません。関数が2つあって、同じようなポジションで動作するので分かりやすくなるかと思います。
別のエーシングストリームを作ることも検討してみてください。突然の話ですが、ここまでで良いアイディアがあれば共有しましょう。需要はそこそこあるかもしれません。この内容をもっと世の中で使えるようにすることは大事だと思います。
実際に使ってみることが大切で、これがセオリーだという内容をしっかり学び、それを他の人にも広めることが重要です。気を使うところは多いですが、しっかり取り組みましょう。
では、今日はここまでにしますね。お疲れ様でした。ありがとうございました。