はじめに
SwiftQueue は lucas34 氏が公開している iOS 向けの job scheduler ライブラリです。
SwiftQueue を使ってみた の続きの記事です。
SwiftQueue がどんなことができるのかは、SwiftQueue を使ってみた を参照ください。
本記事では、内部実装を追ってみた内容を記します。
( ライブラリ本体は 20 クラス程度なのでサクッと読めました。)
コア部分
まず、大まかな処理の流れと登場クラスについてです。
Foundation に含まれる、 OperationQueue
と Operation
をラップしています。
SqOperationQueue
が OperationQueue
を継承しています。
internal final class SqOperationQueue: OperationQueue {
...
}
また、 SqOperation
が Operation
を継承しています。
internal final class SqOperation: Operation {
let handler: Job
var info: JobInfo
let constraints: [JobConstraint]
}
-
Job
はユーザが実行させたい処理の protocol -
JobInfo
は job のパラメータ、実行条件、実行状態などを保持するクラス -
JobConstraint
は job の実行可否を判断する制約の protocol
です。
SqOperation#start()
が実行されるときに、実行条件と合致するか照らし合わせて、実行可能であれば job が実行されます。
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
に充電状態の変更通知を登録しています。
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
というライブラリに、ネットワーク状態の変更通知を登録しています。
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
で遅延実行を実現しています。
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 の実行を再開できます。
JobBuilder(type: MyJob.type)
.persist(required: true) // 永続化指定
.with(params: ["key": "\(key)"])
.schedule(manager: manager)
仕組み
SqOperationQueue
が永続化機構を持っています。
internal final class SqOperationQueue: OperationQueue {
private let creator: JobCreator
private let persister: JobPersister
private let serializer: JobInfoSerializer
}
永続化するタイミング
OperationQueue
の addOperation
のタイミング
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
を生成しないと永続化した処理の再実行が行われません。
init(...) {
...
if synchronous {
self.loadSerializedTasks(name: queueName)
} else {
DispatchQueue.global(qos: DispatchQoS.QoSClass.utility).async { () -> Void in
self.loadSerializedTasks(name: queueName)
}
}
}
永続化したデータの削除のタイミング
job が完了したとき
private func completed(_ job: SqOperation) {
if job.info.isPersisted {
persister.remove(queueName: queueName, taskId: job.info.uuid)
}
...
}
保存先
JobPersister
に則っていればカスタマイズ可能
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)
}
manager = SwiftQueueManagerBuilder(creator: MyJobCreator())
.set(persister: MyPersister()) // カスタム Persister を設定
.build()
デフォルトでは、 UserDefaults
に保存する UserDefaultsPersister
が使用されます。
保存方法
JobInfoSerializer
に則っていればカスタマイズ可能
public protocol JobInfoSerializer {
func serialize(info: JobInfo) throws -> String
func deserialize(json: String) throws -> JobInfo
}
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 のサンプル通り)
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
にマッチしません。
過去のバージョンを考慮して、
if type == "MyJob" || type == MyJob2.type {
return MyJob(params: param)
}
のようにしてあげれば、救済は可能です(が、忘れそうな気がしてなりません)
パフォーマンス懸念(※未計測)
前述の通り、 addOperation
や completed
のたびに永続化データの書き換えが呼び出されます。
デフォルトの UserDefaultsPersister
に大量のデータを頻繁に読み書きするとパフォーマンス問題を引き起こす可能性があります。
また、デフォルトではメインスレッドで restore 処理が実行されてしまいます。
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
を指定することで回避可能です。
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
などを設定できても良さそう