非同期処理が難しい - SwiftでAlamofireの結果を受け取る -
概要
Alamofireとは、SwiftでHTTP通信を行うライブラリです。
ios Swift で下記のアーキテクチャでAPIクライアントを作ろうとしたら、非同期処理に苦しめられました。
ViewController(表示を変える) ↓ UseCase (ビジネスロジック) → Domain (値オブジェクト) ↓ Repository (HTTP通信)
Twitterで例えるなら、
- ツイート読み込みイベントが発火する
- ViewControlle でツイート読み込み操作を受け取る
- UseCase のツイート読み込み処理を実行
- Repository のツイート取得APIを叩く処理を実行
- ツイートが入った JSON を UseCase に返す
- UseCase で JSON を ツイートを Domain に変換
- Domain を ViewController に返す
- ViewController で Domain からツイートを取り出し、タイムラインに表示
みたいなことをしようと思いました。
しかし、Repository でツイート取得API を叩く処理 ( Alamofire ) が実は非同期処理で、結果が上手に受け取れないということが起こりました。
コードで表すなら下記の様に実装していました。(だいぶ簡略化してます)
class Timeline { var tweet: String init(json: JSON) { self.tweet = json['tweet'] } } class TimelineApiRepository { func request() -> JSON { var timelineTweets: JSON Alamofire.request("TwitterAPIのURL").responseJSON { response in if let result = response.result.value { timelineTweets = JSON(result); } } return timelineTweets } class TimelineUseCase { func exec() -> Timeline { // TODO: ここが問題!!!!!!!!!!!!!!!!!!!!!! return Timeline( TimelineRepository().request() ) } } class ViewController: UIViewController { @IBOutlet weak var message: UILabel! override func viewDidLoad() { super.viewDidLoad() Timeline timeline = TimelineUseCase().exec() self.message.text = timeline.tweet } }
上記コードの Timeline( TimelineRepository().request() )
が非同期の為うまく動作しませんでした。
わかりやすく UnitTest で表現すると、下記のようにして動作すると思い込んでいました。(同期処理だと思っていた)
... let repository = TimelineRepository() ... expect( repository.request() ).to(equal(' TwitterAPIから受け取ったJSON(Mockから受け取る) ')) ...
実行直後は処理が完了していない為、上記のテストはエラーになります。
非同期処理について
非同期処理についていろいろ調べていて、下記の単語がごっちゃになったのでざっくり調べました。
- 非同期処理/並行処理/並列処理
- プロセス/スレッド/タスク
- promise/future
- async/await
非同期処理 / 並行処理 / 並列処理
- 同期処理: 処理が順番に行われる処理
- 非同期処理: 処理が順不同に行われる処理
- 並列処理: マルチスレッド、計算負荷を分散させる
- 並行処理: シングルスレッド、処理が順不同に行われる
プロセス / スレッド / タスク
- プロセス: 一つのプログラムの処理
- スレッド: プロセスに含まれる、タスクを実行する
- タスク: スレッドに含まれる、処理の単位
タスク分けされた処理はスレッドにいい感じに分散されて実行されます。(スレッドプール?)
promise/future
非同期処理をいい感じに行うデザインパターン。
- future: 待ち, 成功, 失敗のどれかの状態を持ち、非同期処理が終わっていれば成功か失敗の値を受け取れる
- promise: futureを生成する、成功時, 失敗時の値を定義する
async / await
- async: 非同期で処理を行う
- await: 非同期処理が終わるまでメインスレッドをブロッキングする
何故通信処理が非同期なのか
- ios では描画処理がメインスレッドで行われる
- メインスレッドで重い処理を行うと描画処理が止まってしまう
- なので、通信中も描画が行われるように非同期にする
- 通信が終わるまでメインスレッドをブロッキングするとアプリは固まる
- この解決方法としてはコールバックを渡して処理をする
HTTP通信のレスポンスを受け取るには
上述したように、Repository にクロージャ (コールバック) を渡せば良さそうなことがわかりました。
class Timeline { var tweet: String init(json: JSON) { self.tweet = json['tweet'] } } class TimelineApiRepository { func request(closure: closure){ // TODO: そもそもここのクロージャを Presenter で定義しても良いかも Alamofire.request("TwitterAPIのURL").responseJSON { response in if let result = response.result.value { // TODO: レスポンスが返ってきたら、クロージャにレスポンスを渡す // TODO: このクロージャ内でツイートがviewに設定され、画面に反映される (はず) closure(Timeline(result)) } } } class TimelineUseCase { func exec(closure: closure) { // TODO: 受け取ったクロージャを Repository に渡す TimelineRepository().request(closure: closure) } } class ViewController: UIViewController { @IBOutlet weak var message: UILabel! override func viewDidLoad() { super.viewDidLoad() // TODO: クロージャを定義 let closure = { timeline in self.message.text = timeline.tweet } // TODO: UseCase にクロージャを渡す TimelineUseCase().exec( closure: closure ) } }
上記のようにすることで API のレスポンスを view に反映させることが出来るはずです。
(各層の依存関係がおかしくなってしまったので別途修正します)
ただ、このままだとコールバック地獄とかいろいろ辛くなると思うので、その場合は BrightFutures というライブラリを使うと Scala の Future のような感覚で非同期処理が扱えるようになるようです。
因みに、UnitTest は下記のようになりました。
... var json: Json let closure = { (result: JSON) -> () in json = result } let repository = TimelineRepository() ... repository.request(closure: closure) expect( json ).toEventually(equal(' TwitterAPIから受け取ったJSON(モックから受け取る) '), timeout: 5, pollInterval: 1, description: "") ...
まとめ
- 非同期処理は難しい
- メインスレッドを止めて値を受け取る処理はUI処理も止めてしまうので絶対ダメ
- でもクロージャを渡せばノンブロッキングでなんとかなる
- クロージャは Presenter から UseCase を通り、Repository で実行される
- クロージャ実行時は Presenter のプロパティを操作出来るので問題無い
- BrightFuture はクロージャだけでやるとコールバック地獄が辛いので使う
- 非同期処理を一杯書いてるとグチャグチャになりそうだから注意する
非同期処理をちゃんと書いたのは初めてだったのでとても苦労しています。
この記事も間違っているかもしれないので、マサカリありましたら投げて頂けると幸いです。