SwiftQueue の内部を追ってみた

# はじめに
[SwiftQueue](https://github.com/lucas34/SwiftQueue) は [lucas34](https://github.com/lucas34) 氏が公開している iOS 向けの job scheduler ライブラリです。

[SwiftQueue を使ってみた](https://qiita.com/drafts/b072cae4da0ff9174d84) の続きの記事です。
SwiftQueue がどんなことができるのかは、[SwiftQueue を使ってみた](https://qiita.com/drafts/b072cae4da0ff9174d84) を参照ください。

本記事では、内部実装を追ってみた内容を記します。
（ ライブラリ本体は 20 クラス程度なのでサクッと読めました。）

# コア部分
まず、大まかな処理の流れと登場クラスについてです。
Foundation に含まれる、 `OperationQueue` と `Operation` をラップしています。

`SqOperationQueue` が `OperationQueue` を継承しています。

```swift:SqOperationQueue.swift
internal final class SqOperationQueue: OperationQueue {
    ...
}

```
また、 `SqOperation` が `Operation` を継承しています。

```swift:SqOperation.swift
internal final class SqOperation: Operation {
    let handler: Job
    var info: JobInfo
    let constraints: [JobConstraint]
}
```
* `Job` はユーザが実行させたい処理の protocol
* `JobInfo` は job のパラメータ、実行条件、実行状態などを保持するクラス
* `JobConstraint` は job の実行可否を判断する制約の protocol

です。
`SqOperation#start()` が実行されるときに、実行条件と合致するか照らし合わせて、実行可能であれば job が実行されます。

```swift:SqOperation.swift
override func start() {
    ...
    run()
}

internal func run() {
    ...
    do {
        try self.willRunJob() // 将来も実行可能かチェック
    } catch let error {
        // 実行期限切れやリトライカウント超過など、もう実行する必要がないものはキャンセル
        cancel(with: error)
        return
    }
    guard self.checkIfJobCanRunNow() else {
        // 現時点で実行可能かチェック
        // オンラインが必須だが、オフラインの時はキャンセルも実行もしない
        return
    }
    handler.onRun(callback: self) // job の実行
}

func checkIfJobCanRunNow() -> Bool {
    // 各 JobConstraint に実行条件がマッチしているか問い合わせ
    for constraint in self.constraints where constraint.run(operation: self) == false {
        return false
    }
    return true
}
```

# 実行可能な状態になったときに自動実行する仕組み
例えば、 **充電中であること** を実行条件にした job が充電中でない時に実行されると、即時実行されません。
キャンセルされない限りは、充電中になったタイミングで自動で再実行されます。
これがどのような仕組みで実現されているか見てみます。

## 充電状態
`NotificationCenter` に充電状態の変更通知を登録しています。

```swift:BatteryChargingConstraint.swift
internal final class BatteryChargingConstraint: JobConstraint {
    // To avoid cyclic ref
    private weak var actual: SqOperation?

    // 実行可否判断
    func willSchedule(queue: SqOperationQueue, operation: SqOperation) throws {
        // 実行条件に充電中が指定されていないなら即 return
        guard operation.info.requireCharging else { return } 

        // 充電状態の変更監視を NotificationCenter に登録
        NotificationCenter.default.addObserver(
                self,
                selector: Selector(("batteryStateDidChange:")),
                name: UIDevice.batteryStateDidChangeNotification,
                object: nil
        )
    }

    // 充電状態が変わったら呼び出される
    func batteryStateDidChange(notification: NSNotification) {
        if let job = actual, UIDevice.current.batteryState == .charging {
            // Avoid job to run multiple times
            actual = nil
            job.run() // job の実行
        }
    }

}
```

## ネットワーク状態
ネットワークの状態を簡単に扱える `Reachability` というライブラリに、ネットワーク状態の変更通知を登録しています。

```swift:NetworkConstraint.swift
import Reachability
internal final class NetworkConstraint: JobConstraint {
    ...
    func run(operation: SqOperation) -> Bool {
        ...
        reachability.whenReachable = { reachability in
            reachability.stopNotifier()
            reachability.whenReachable = nil
            operation.run()
        }
        ...
    }
}
```
## 指定時間後（Delay や backoff）の実行
`DispatchQueue#asyncAfter` で遅延実行を実現しています。

```swift:DelayConstraint.swift
internal final class DelayConstraint: JobConstraint {
    func run(operation: SqOperation) -> Bool {
        ...
        runInBackgroundAfter(time, callback: { [weak operation] in
            // 指定時間後に再チェック
            operation?.run()
        })
    }
}

func runInBackgroundAfter(_ seconds: TimeInterval, callback: @escaping () -> Void) {
    let delta = DispatchTime.now() + seconds
    DispatchQueue.global(qos: DispatchQoS.QoSClass.utility).asyncAfter(deadline: delta, execute: callback)
}
```
## カスタムの JobConstraint を定義できるか？
例えば…「バッテリーが充分にあるとき」（※充電中でなくても充分あれば OK）
電源に接続されていても、残り 1% だと実行させたくなく、充分に（30%とか？）充電されたら実行したい　など。
（ ※Android の WorkManager では `setRequiresBatteryNotLow` という指定が可能です。）

**→　できない。**

`SqOperation` の `let constraints: [JobConstraint]` に追加できればできそうだが、その手段はなさそう。。。

# 永続化
job に `persist: ture` を設定しておくと、 job の状態（パラメータや未実行など）を永続化できます。
これにより、アプリを終了したり端末を再起動しても、途中の状態を復帰させることができ、Queue での Operation の実行を再開できます。

```swift
JobBuilder(type:  MyJob.type)
    .persist(required: true) // 永続化指定
    .with(params: ["key": "\(key)"])
    .schedule(manager: manager)
```

## 仕組み
`SqOperationQueue` が永続化機構を持っています。

```swift:SqOperationQueue.swift
internal final class SqOperationQueue: OperationQueue {
    private let creator: JobCreator
    private let persister: JobPersister
    private let serializer: JobInfoSerializer
}
```

### 永続化するタイミング
`OperationQueue` の `addOperation` のタイミング
 
```swift:SqOperationQueue.swift
override func addOperation(_ ope: Operation) {
    self.addOperationInternal(ope, wait: true)
}
private func addOperationInternal(_ ope: Operation, wait: Bool) {
    ...
    if job.info.isPersisted {
        persistJob(job: job) // 永続化指定されていれば永続化
    }
    ...
    super.addOperation(job)
}
```

### 永続化したデータを読み出すタイミング
`SqOperationQueue` の生成時
→　その呼び出し元をたどると、 `SwiftQueueManager` の生成時
**つまり、 `SwiftQueueManager` を生成しないと永続化した処理の再実行が行われません。**

```swift:SqOperationQueue.swift
init(...) {
    ...
    if synchronous {
        self.loadSerializedTasks(name: queueName)
    } else {
        DispatchQueue.global(qos: DispatchQoS.QoSClass.utility).async { () -> Void in
            self.loadSerializedTasks(name: queueName)
        }
    }
}
```

### 永続化したデータの削除のタイミング
job が完了したとき

```swift:SqOperationQueue.swift
private func completed(_ job: SqOperation) {
    if job.info.isPersisted {
        persister.remove(queueName: queueName, taskId: job.info.uuid)
    }
    ...
}
```

### 保存先
`JobPersister` に則っていればカスタマイズ可能

```swift
public protocol JobPersister {
    func restore() -> [String]
    func restore(queueName: String) -> [String]
    func put(queueName: String, taskId: String, data: String)
    func remove(queueName: String, taskId: String)
}
```

```swift
manager = SwiftQueueManagerBuilder(creator: MyJobCreator())
    .set(persister: MyPersister()) // カスタム Persister を設定
    .build()
```
デフォルトでは、 `UserDefaults` に保存する `UserDefaultsPersister` が使用されます。

### 保存方法
`JobInfoSerializer` に則っていればカスタマイズ可能

```swift
public protocol JobInfoSerializer {
    func serialize(info: JobInfo) throws -> String
    func deserialize(json: String) throws -> JobInfo
}
```

```swift
manager = SwiftQueueManagerBuilder(creator: MyJobCreator())
    .set(serializer: MyJobInfoSerializer()) // カスタム JobInfoSerializer を設定
    .build()
```
デフォルトでは、 `Decodable` を使った `DecodableSerializer` が使用されます。

なお、実際に serialize されたデータがこちら。

```
{
	"isPersisted": true,
	"uuid": "CAE6D2BA-E106-4A59-A39A-2FDAAD04158C",
	"requireNetwork": 0,
	"override": false,
	"delay": null,
	"interval": 0,
	"params": "{\"key\":\"9\"}",
	"tags": [],
	"runCount": -1,
	"type": "MyJob",
	"createTime": 569477225.450701,
	"group": "GLOBAL",
	"retries": {
		"value": 0
	},
	"requireCharging": false,
	"maxRun": {
		"value": 0
	},
	"deadline": null
}
```


### 永続化利用時の注意

#### 永続化データと実装の整合性
上記 json データを見てもらえればわかりますが、そのデータがどのクラスに復元されるかが文字列で保持されています。

```
	"type": "MyJob", // ← MyJob.swift に復元されることを期待
```
実際の復元コードがこちら（ほぼ、 Github のサンプル通り）

```swift
class MyJobCreator: JobCreator {
    func create(type: String, params param: [String: Any]?) -> Job {
        // check for job and params type
        if type == MyJob.type  {
            return MyJob(params: param)
        } else {
            // Nothing match
            // You can use `fatalError` or create an empty job to report this issue.
            fatalError("No Job !")
        }
    }
}
```
自作の Creator クラスで `type` にマッチした Job インスタンスを返してあげる必要があります。

永続化データは `MyJob` であっても、クラス名のリネームを行って `MyJob2` などにしてしまった場合は `MyJob` にマッチしません。
過去のバージョンを考慮して、

```swift
if type == "MyJob" || type == MyJob2.type  {
    return MyJob(params: param)
}
```
のようにしてあげれば、救済は可能です（が、忘れそうな気がしてなりません）

#### パフォーマンス懸念（※未計測）
前述の通り、 `addOperation` や `completed` のたびに永続化データの書き換えが呼び出されます。
デフォルトの `UserDefaultsPersister` に大量のデータを頻繁に読み書きするとパフォーマンス問題を引き起こす可能性があります。

また、デフォルトではメインスレッドで restore 処理が実行されてしまいます。

```swift:SqOperationQueue.swift
init(...) {
    ...
    if synchronous { // デフォルト true
        self.loadSerializedTasks(name: queueName)
    } else {
        DispatchQueue.global(qos: DispatchQoS.QoSClass.utility).async { () -> Void in
            self.loadSerializedTasks(name: queueName)
        }
    }
}
```

これは、 `SwiftQueueManager` 生成時に `synchronous: false` を指定することで回避可能です。

```swift
manager = SwiftQueueManagerBuilder(creator: MyJobCreator())
    .set(synchronous: false)
    .build()
```

また、画像データなどのバイナリデータは base64 エンコード等でパラメータに全データを積むことは可能ですが、ファイルのパスだけでやりとりするのがパフォーマンス面でも有利だといえそうです。

※ Android の WorkManager のパラメータには 10kb の制限があります。
https://developer.android.com/reference/androidx/work/Data.html#MAX_DATA_BYTES

# まとめ・雑感
* `OperationQueue` と `Operation` のラッパーライブラリ
* 永続化は色々気をつけないとハマりそう
* Android の JobScheduler や WorkManager との機能互換があって便利そうだけど、105 star しかないのがやや不安
* Android ではフレームワーク側で対応しているので、iOS でもフレームワーク側で提供して欲しい
* （↑ iOS は通信環境が悪い途上国のユーザが少なかったり、安価だけど電池の持ちが悪いような端末がないからフレームワークでサポートする必要性がそんなにないから…？？）
* `OperationQueue` の `maxConcurrentOperationCount` や `qualityOfService` などを設定できても良さそう