LoginSignup
2
1

More than 5 years have passed since last update.

SwiftQueue の内部を追ってみた

Posted at

はじめに

SwiftQueuelucas34 氏が公開している iOS 向けの job scheduler ライブラリです。

SwiftQueue を使ってみた の続きの記事です。
SwiftQueue がどんなことができるのかは、SwiftQueue を使ってみた を参照ください。

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

コア部分

まず、大まかな処理の流れと登場クラスについてです。
Foundation に含まれる、 OperationQueueOperation をラップしています。

SqOperationQueueOperationQueue を継承しています。

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

また、 SqOperationOperation を継承しています。

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 が実行されます。

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 に充電状態の変更通知を登録しています。

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 というライブラリに、ネットワーク状態の変更通知を登録しています。

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 で遅延実行を実現しています。

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 という指定が可能です。)

→ できない。

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

永続化

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

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

仕組み

SqOperationQueue が永続化機構を持っています。

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

永続化するタイミング

OperationQueueaddOperation のタイミング

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 を生成しないと永続化した処理の再実行が行われません。

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

永続化したデータの削除のタイミング

job が完了したとき

SqOperationQueue.swift
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)
}

のようにしてあげれば、救済は可能です(が、忘れそうな気がしてなりません)

パフォーマンス懸念(※未計測)

前述の通り、 addOperationcompleted のたびに永続化データの書き換えが呼び出されます。
デフォルトの UserDefaultsPersister に大量のデータを頻繁に読み書きするとパフォーマンス問題を引き起こす可能性があります。

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

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 を指定することで回避可能です。

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

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

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

まとめ・雑感

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

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
1