はじめに
この記事は【Swift】進捗をTwitterのアイコンに(Fleetっぽく)表示する の続きです。
※内容的には分割されています
前回の反省
前回の記事の最後に、ちらっとこんなことを書いていました。
コールバック地獄のクソコード
RunLoopなんもわからん
記事では深く触れなかったのですが、実は前回のコードはとんでもなく汚いものでした。
コールバックにコールバックが重なり、RunLoop.main.run()
という謎のおまじないをファイル末尾に追加して無理矢理動かすなど、正直こんなんでよく記事にしたなと言われても仕方ないレベルの仕上がりとなってしまっていました。
今回はこのあたりの処理について**†完全に理解した†(非常に危険な発言)**ので、備忘録として残しておきます。
SwiftPMでの非同期処理
SwiftPMで通常のアプリケーションと同様に非同期処理を走らせようとすると、若干の不都合が生じます。
例えばURLSession
を動かそうとした場合…
let url = URL(string: "https://example.com")!
let task = URLSession.shared.dataTask(with: url) { (data, response, error) in
if let error = error{
fatalError(error.localizedDescription)
}
print(String(data: data!, encoding: .utf8)!)
}
task.resume()
$ swift run
# しかし なにもおこらない
何も起こらずプログラムが終了してしまいます。 至極当然です。
これはメインスレッドが非同期処理の完了を待機できていないことが原因です。
DispatchSemaphore
で処理の完了を待つ
DispatchSemaphore
を使用することで、この問題を解決することができます。
let sema = DispatchSemaphore(value: 0)
let url = URL(string: "https://example.com")!
let task = URLSession.shared.dataTask(with: url) { (data, response, error) in
if let error = error{
fatalError(error.localizedDescription)
}
print(String(data: data!, encoding: .utf8)!)
sema.signal() // 処理の終了を通知
}
task.resume()
sema.wait() // ここでメインスレッドがストップし、非同期処理の完了を待機してくれる
実行すると、リクエストが完了するまでしっかり待機してくれます。
$ swift run
<!doctype html>
<html>
<head>
...
</head>
<body>
...
</body>
</html>
Program ended with exit code: 0
Swifterが動かない
あとはこれをSwifterにも適用すれば…と、思ったのですが。
import Swifter
let sema = DispatchSemaphore(value: 0)
let swifter = Swifter(...)
swifter.showUser(.id("1243395100387897347"), includeEntities: true) { (json) in
print(json)
sema.signal()
} failure: { (error) in
print(error)
sema.signal()
}
sema.wait()
$ swift run
# しかし なにもおこらない
なにも起こりません。それどころかプログラムが停止しないのです。
さらにXCodeで見るとネットワーク通信すら行われていないことがわかります。
さすがにこれはSwifterのバグだろうと思い(大変失礼)、issueを検索すると…
Swifter is not reacting (#260)
https://github.com/mattdonnelly/Swifter/issues/260
ありました。
collaboratorのmeteochu氏によると「wait
の前にRunLoop.main.run()
入れてや(意訳)」とのことだったので、該当行に挿入し実行してみたのですが…
$ swift run
...
SwifterError(message: "HTTP Status 400: Bad Request, ...", errorCode: 215)
ネットワーク通信は行われたものの、相変わらずプログラムが終了しません。
前回の記事ではここでお手上げになってしまい、コールバック地獄の果てにexit()
で逃げるという原始的な方法で無理やり解決してしまっていました。
何故動かなかったのか
どうしても気になって仕方がなかったので、Swifter.showUser()
の実装を調べてみることにしました。
まずshowUser
の定義は SwifterUsers.swiftに記述されています。
func showUser(...) {
...
self.getJSON(path: path, baseURL: .api, parameters: parameters, success: { json, _ in
success?(json)
}, failure: failure)
}
引数に渡されたコールバックを直接getJSON()
のsuccess
, failure
に渡していることがわかります。
このgetJSON
の実装はSwifter.swiftに記述されています。
@discardableResult
internal func getJSON(...) -> HTTPRequest {
return self.jsonRequest(path: path, baseURL: baseURL, method: .GET, parameters: parameters,
uploadProgress: uploadProgress, downloadProgress: downloadProgress,
success: success, failure: failure)
}
引数をjsonRequest
に渡してHTTPRequest
を作成し、returnしているようです。
jsonRequest
の実装はSwifter.swiftにあります。
@discardableResult
internal func jsonRequest(...) -> HTTPRequest {
...
let jsonSuccessHandler: HTTPRequest.SuccessHandler = { data, response in
DispatchQueue.global(qos: .utility).async {
do {
let jsonResult = try JSON.parse(jsonData: data)
DispatchQueue.main.async {
success?(jsonResult, response)
}
} catch {
DispatchQueue.main.async {
if case 200...299 = response.statusCode, data.isEmpty {
success?(JSON("{}"), response)
} else {
failure?(error)
}
}
}
}
}
...
let jsonSuccessHandler: ...
以降の処理をよく読むと、
- 優先度
.utility
のDispatchQueue
で - JSONをパースし
DispatchQueue.main
でsuccess
を呼び出す
ようになっています。
URLSession
のコールバックと違い、Swifter
のコールバックはメインスレッドで実行される設計になっているのです。(これは UIの更新はメインスレッドで行われなければならない というUIKitの性質を見越した設計だと思われます)
これを踏まえて、もう一度先ほどのコードを読んでみます。
import Swifter
let sema = DispatchSemaphore(value: 0)
let swifter = Swifter(...)
swifter.showUser(.id("1243395100387897347"), includeEntities: true) { (json) in
print(json)
sema.signal()
} failure: { (error) in
print(error)
sema.signal()
}
sema.wait()
- Swifterインスタンスを生成し
-
showUser
を実行、コールバック内でsema.signal
を呼び出すよう設定 -
メインスレッドで
sema.signal
が呼び出されるのを待つ
つまり、showUser
が実行される頃にはメインスレッドはsemaを待機してしまっているのです。
動くワケがありません。
解決策
メインスレッドでsemaphoreを待機してしまっているのが問題なので、他のDispatchQueue
で待機させるようにすれば想定通り動作します。
let swifter = Swifter(consumerKey: "", consumerSecret: "")
DispatchQueue.global().async {
let sema = DispatchSemaphore(value: 0)
swifter.showUser(.id("1243395100387897347"), includeEntities: true) { (json) in
print(json)
sema.signal()
} failure: { (error) in
print(error)
sema.signal()
}
sema.wait()
exit(EXIT_SUCCESS)
}
dispatchMain()
dispatchMain
はメインスレッドを一時停止し、DispatchQueue.mainに積まれたブロックの処理に専念させる関数です。
dispatchMain()
This function "parks" the main thread and waits for blocks to be submitted to the main queue.
(引用:https://developer.apple.com/documentation/dispatch/1452860-dispatchmain)
$ swift run
[1/1] Merging module XXXXApp
SwifterError(message: "HTTP Status 400:....", ... errorCode: 215)
$ # <- プログラムが正常に終了している!
ネットワーク通信も正常に行われ、非同期処理が完了したタイミングでプログラムが終了するようになりました。
最後に
ここまで読んでいただきありがとうございました。
改善版のソースコードは前回同様GitHubにて公開しておりますので、「結局何も理解してないじゃねーか!」等ございましたらコメントまたはPRいただければ幸いです。
ではまた!