SwiftTask(Promise拡張)を使う

  • 163
    Like
  • 10
    Comment
More than 1 year has passed since last update.

前回の

  1. Swiftで有限オートマトン(ステートマシン)を作る - Qiita
  2. Swift+有限オートマトンでPromiseを拡張する - Qiita

の続きです。今回は、拙作の「SwiftTask」というライブラリの紹介です。

SwiftTask
https://github.com/inamiy/SwiftTask

SwiftTaskについて

SwiftTaskは、PromiseKitBolts-iOSと同じく、Promiseライクなフロー制御ライブラリです。
主に以下の特徴があります。

  • ProgressPause/ResumeCancelのインターフェースを新たに追加(Promiseを高機能化)
  • ジェネリクスを活用し、ほぼ Pure Swift (Cocoa Frameworkに依存しない)
  • SwiftTask単体は 非同期(スレッド、イベントループ)処理を持たない、薄いラッパークラス
    • 基本的に、非同期処理はタスク内部(初期化クロージャ)で行う(後述のAlamofireの例を参考)
    • 複数タスクにまたがる協調的な処理(Task.all()など)では、最低限の排他制御のみを行う
  • 引数1つのtrailing closureを使った、簡易的な method chaining

他ライブラリとの比較は、SwiftTask自身に非同期処理がないため簡単ではありませんが、参考までに表にします(2014/08/30現在。個人的な感想です)

ライブラリ progress pause cancel 直列処理 協調的処理
NSOperation/Queue ×
実行中は止まらない
×
addDependencyつらい
×
addDependencyつらい
PromiseKit × × ×
thenの実装が不十分?

whenのみ
Bolts-iOS × ×
外部token方式

allのみ
SwiftTask
インターフェースのみ

インターフェースのみ

インターフェースのみ

all, any, someなど

使用例

SwiftTaskでタスクを定義する際(初期化クロージャ内)、直接Grand Central Dispatch等を使って非同期処理を行うことも可能です。
が、Alamofire(ネットワークライブラリ)に代表されるような、

  • obj.setProgressHandler() (callback)
  • obj.setCompletionHandler() (callback)
  • obj.pause()
  • obj.resume()
  • obj.cancel()

のAPIの形に「別クラスで一旦ラップする」方が、より簡単に実装することができます。

Alamofireを使った例

typealias Progress = (bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64)

// define task
let task = Task<Progress, NSData?, NSError> { (progress, fulfill, reject, configure) in

    let request = Alamofire.download(.GET, "http://httpbin.org/stream/100", destination: somewhere)

    request.progress { (bytesWritten, totalBytesWritten, totalBytesExpectedToWrite) in

        progress((bytesWritten, totalBytesWritten, totalBytesExpectedToWrite) as Progress)

    }.response { (request, response, data, error) in

        if let error = error {
            reject(error)
            return
        }

        fulfill(data as NSData?)

    }

    configure.pause  = { request.suspend() }
    configure.resume = { request.resume() }
    configure.cancel = { request.cancel() }

    return
}

上のコードは、NSURLSessionDownloadTaskを内部で使った、簡単なダウンロード処理のサンプルです。

taskを初期化するにあたり、まずジェネリクスで表記されたProgressValue (fulfilled)Error (rejected)の型を決める必要があります(Progressは必ずしも、0.0〜1.0までのFloat型などである必要はありません)。

Task<Progress, Value, Error>

続いて、初期化クロージャを使って、引数で渡されるprogressfulfillrejectconfigureを内部で実行します。このとき、

  • fulfillrejectの呼び出し方は、従来のPromiseの時と同じ
  • progressは、fulfill or rejectを呼ぶまで、何回も実行できる(optional)
  • configureで、pauseresumecancelを実装する(optional)

なお、Alamofireは、元々Cocoaがデリゲートメソッドで用意しているprogress部分を コールバック形式に変換している ので、Alamofire → SwiftTaskへのprogressの伝搬が上述のように非常に簡単に書けます。

Method Chaining

Promiseのようにmethod chainingをする方法は、次の通りです。

task.progress { progress in

    println("bytesWritten = \(progress.bytesWritten)")
    println("totalBytesWritten = \(progress.totalBytesWritten)")
    println("totalBytesExpectedToWrite = \(progress.totalBytesExpectedToWrite)")

}.then { (value: NSData?) -> Void in 

    println("fulfilled: \(value)")

}.catch { (errorInfo: ErrorInfo) -> Void in

    println("rejected: \(errorInfo.error) \(errorInfo.isCancelled)")

}

progress

まず、task.progress(progressClosure)を実行すると、taskprogressClosureが登録されます。
そして、先ほどの初期化クロージャ内で引数のprogressハンドラが実行される度に、初期化クロージャの外側に位置するprogressClosureが実行される仕組みになっています。
ちなみに、task.progress()の返り値は、同じtaskなので、そのまま後続のthen/catchにつなぐことができます。

then

task.then(closure)は、 1つのclosure引数のみを受け取り、新しいtaskを返すメソッドです。
基本的に、taskfulfilled になった場合は、closureが必ず呼ばれます。

ところで、JavaScript Promiseの場合、

  1. promise.then(onFulfilled) (fulfilled only)
  2. promise.then(onFulfilled, onRejected) (fulfilled & rejected)

のように、最大2つの引数を取りながら両者を区別していますが、SwiftTaskでは 引数を1つのclosureに制限+オーバーロード+型推論 して区別しています。このメリットとして、

  • いつでもtask.then {...}.then {...}の形で書ける(()がいらない)
  • onFulfilled/onRejectedに分けるより、Apple流のcompletionHandlerに統一した方が、APIデザイン的に良い

があります。

thenが受け取るclosureの型の詳細については、README.mdをご覧ください。

catch

thenと同様、task.catch(closure)は、 1つのclosure引数を受け取り、新しいtaskを返します。
taskrejected になった場合のみ、closureが呼ばれます。

ここで、closureの引数はErrorオブジェクトではなく、Task.ErrorInfoという(error: Error?, isCancelled: Bool)のTuple型になっています。
errorInfo.isCancelledを見ることによって、キャンセルによるrejectedかどうかの判断ができます。

Pause/Resume/Cancel

初期化クロージャのconfigureで設定したpause/resume/cancelは、外側からtaskオブジェクトの各メソッドを直接呼ぶことで実行できます。

task.pause()
task.resume()
task.cancel() // task.cancel(error)もできる

キャンセルされたtaskは、内部でrejectされたものとほぼ同じ形で、catchなどで捕捉することができます。

番外:Custom Operatorを使う

まだ試験的な実装ですが、progressthencatchをカスタム演算子に置き換えることもできます。

  • task ~ {...} = task.progress { progress in ...}
  • task >>> {...} = task.then { value, errorInfo in ...} (fulfilled & rejected)
  • task *** {...} = task.then { value in ...} (fulfilled only)
  • task !!! {...} = task.catch { errorInfo in ...} (rejected only)

さっそく試してみましょう。

task ~ { progress in
    ...
} *** { (value: NSData?) -> String in 
    ...
} !!! { (errorInfo: ErrorInfo) -> String in
    ...
} >>> { (value, errorInfo) -> Void in
    ...
}

Ship it!:shipit: thenの処理の違いが、より見やすくなりました。
こちらも便利だと思うので、ぜひ使ってみてください。
(別の記法でもっと良いものがあったら、教えてください)