【Swift】バックグラウンド的な処理をタイマーで実装してみた

どうも、ねこきち(<a href="https://mobile.twitter.com/nekokichi1_yos2">@nekokichi1_yos2</a>)です。

Swiftにはバックグラウンド処理を実装する方法はありますが、
・**特定の用途に限定**
・**汎用的な方法もあるが短時間**
・**長時間の方法はAppleが推奨してない**
・**処理が安定しない**
の理由で扱いが難しいです。

しかし、簡単な処理ならば、バックグラウンド処理のメソッドを使用しなくても、バックグラウンドは実現できます。

**そこで、デリゲートを使って、バックグラウンドに対応したタイマーを実装します。**

## 解説

実装する機能は、
・タイマー
・バックグラウンドとのやりとり
・デリゲート
の３つ。

処理の流れは、下記の通り。
1. ボタン押下
2. タイマー起動
3. バックグラウンドに移行
4. アプリ画面に復帰
5. バックグラウンドでの経過時間を残り時間から引く
6. タイマー終了

使用するのは、
- backgroundTimer.swift
- SceneDelegate.swift
のファイルです。

### タイマーの設定

まずは、タイマーをちょちょいと。

```swift:backgroundTimer.swift
import UIKit

class backgroundTimer: UIViewController {
    
    @IBOutlet weak var currentTimeLabel: UILabel!
    @IBOutlet weak var start: UIButton!
    //タイマー
    var timer:Timer!
    //残り時間
    var currentTime = 15
    
    override func viewDidLoad() {
        super.viewDidLoad()
        currentTimeLabel.text = "15"
    }
    
    @IBAction func start(_ sender: Any) {
        timer = Timer.scheduledTimer(timeInterval: 1.0, target: self, selector: #selector(advancedTime), userInfo: nil, repeats: true)
        start.isHidden = true
    }
    
    @objc func advancedTime() {
        //残り時間が1秒以上あるか
        if currentTime >= 1 {
            currentTime -= 1
            currentTimeLabel.text = "\(currentTime)"
        } else {
            timer.invalidate()
            start.isHidden = false
            currentTime = 15
            currentTimeLabel.text = "\(currentTime)"
        }
    }
    
}
```

### バックグラウンドの設定

SceneDelegateでは、
・バックグラウンドでの経過時間を取得
・デリゲートによるバックグラウンド関連の処理
を任せています。

#### バックグラウンドでの経過時間を取得

バックグラウンド中の時間は、
**アプリ画面へ復帰した時の時刻 - バックグラウンドへ移行時の時刻**
で算出してます。

UserDefaultで移行時の時刻を一時保持しておき、アプリ画面へ復帰する際に現在時刻を取得し、2つの時刻を引けば、指定した単位で時間を算出（今回は秒）。

```swift:SceneDelegate.swift
class SceneDelegate: UIResponder, UIWindowSceneDelegate {

    let ud = UserDefaults.standard

    //アプリ画面に復帰した時
    func sceneDidBecomeActive(_ scene: UIScene) {
        if バックグラウンドに移行した？ {
            let calender = Calendar(identifier: .gregorian)
            let date1 = ud.value(forKey: "date1") as! Date
            let date2 = Date()
            let elapsedTime = calender.dateComponents([.second], from: date1, to: date2).second!
            /*ここで経過時間をタイマーに渡す*/
        }
    }

    //アプリ画面から離れる時（ホームボタン押下、スリープ）
    func sceneWillResignActive(_ scene: UIScene) {
        ud.set(Date(), forKey: "date1")
        /*ここでバックグラウンドへの移行を検知*/
        /*ここでタイマーを破棄*/
    }
}
```

### デリゲートの設定

デリゲートで実装したいのは、
・**バックグラウンドへの移行を検知**
・**バックグラウンド時にタイマーを破棄**
・**バックグラウンドの経過時間をタイマーに渡す**
の３つです。

そして、用意するデリゲートの変数、関数は、下記の通り。

```swift:SceneDelegate.swift
protocol backgroundTimerDelegate: class {
    //バックグラウンドの経過時間を渡す
    func setCurrentTimer(_ elapsedTime:Int)
    //バックグラウンド時にタイマーを破棄
    func deleteTimer()
    //バックグラウンドへの移行を検知
    func checkBackground()
    //バックグラウンド中かどうかを示す
    var timerIsBackground:Bool { set get }
}
```

#### SceneDelegate.swift

デリゲートを検知する側（SceneDelegate.swift）にデリゲートを作っておき、デリゲートメソッドで復帰後にタイマーを再実行できるように諸々を処理しています。

```swift:SceneDelegate.swift
class SceneDelegate: UIResponder, UIWindowSceneDelegate {

    var window: UIWindow?
    //デリゲート
    weak var delegate: backgroundTimerDelegate?
    let ud = UserDefaults.standard

    //アプリ画面に復帰した時
    func sceneDidBecomeActive(_ scene: UIScene) {
        //タイマー起動中にバックグラウンドへ移行した？
        if delegate?.timerIsBackground == true {
            let calender = Calendar(identifier: .gregorian)
            let date1 = ud.value(forKey: "date1") as! Date
            let date2 = Date()
            let elapsedTime = calender.dateComponents([.second], from: date1, to: date2).second!
            //経過時間（elapsedTime）をbackgroundTimer.swiftに渡す
            delegate?.setCurrentTimer(elapsedTime)
        }
    }

    //アプリ画面から離れる時（ホームボタン押下、スリープ）
    func sceneWillResignActive(_ scene: UIScene) {
        ud.set(Date(), forKey: "date1")
        //タイマー起動中からのバックグラウンドへの移行を検知
        delegate?.checkBackground()
        //タイマーを破棄
        delegate?.deleteTimer()
    }
}
```


#### backgroundTimer.swift

デリゲートの処理を実行する側（backgroundTimer.swift）にデリゲート、デリゲートの変数・関数郡の設定をします。

```swift:backgroundTimer.swift

class backgroundTimer: UIViewController,backgroundTimerDelegate {
    
    @IBOutlet weak var currentTimeLabel: UILabel!
    @IBOutlet weak var start: UIButton!
    //タイマー起動中にバックグラウンドに移行したか
    var timerIsBackground = false
    var timer:Timer!
    var currentTime = 15
    
    override func viewDidLoad() {
        super.viewDidLoad()
        //SceneDelegateを取得
        guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
            let sceneDelegate = windowScene.delegate as? SceneDelegate else {
            return
        }
        //デリゲートを設定
        sceneDelegate.delegate = self
    }

    func checkBackground() {
        //バックグラウンドへの移行を確認
        if let _ = timer {
            timerIsBackground = true
        }
    }
    
    func setCurrentTimer(_ elapsedTime:Int) {
        //残り時間から引数(バックグラウンドでの経過時間)を引く
        currentTime -= elapsedTime
        currentTimeLabel.text = "\(currentTime)"
        //再びタイマーを起動
        timer = Timer.scheduledTimer(timeInterval: 1.0, target: self, selector: #selector(advancedTime), userInfo: nil, repeats: true)
    }
    
    func deleteTimer() {
        //起動中のタイマーを破棄
        if let _ = timer {
            timer.invalidate()
        }
    }
    
}
```


##### バックグラウンドへの移行を検知

タイマー起動中にバックグラウンドへの移行した時、のみを検知するために、タイマーが起動中かどうかをif文でチェックしています。

もしチェックしなければ、**タイマーを起動してない状態からバックグラウンドへの移行も検知してしまいます。**

```swift:backgroundTimer.swift
    func checkBackground() {
        //バックグラウンドへの移行を確認
        if let _ = timer {
            timerIsBackground = true
        }
    }
```

##### バックグラウンドへの移行時にタイマーを破棄

checkBackground()と同様に、タイマーが起動中かをチェックしてから、タイマーを破棄しています。

残念ながら、Timerクラスには一時停止する機能がないので、タイマー処理を止めるには破棄するしかありません。

```swift:backgroundTimer.swift
    func deleteTimer() {
        //起動中のタイマーを破棄
        if let _ = timer {
            timer.invalidate()
        }
    }
```


##### バックグラウンドでの経過時間をタイマーに渡す

SceneDelegateで算出した経過時間を引数でタイマーに渡します。

バックグラウンドへ移行時にタイマーが破棄されたので、残り時間のcurrentTimeはカウントダウンされないままだったので、引数で受け取った経過時間を引きます。

また、アプリ画面へ復帰時に実行されるので、タイマー処理を再実行してます。

```swift:backgroundTimer.swift
    func setCurrentTimer(_ elapsedTime:Int) {
        //残り時間から引数(バックグラウンドでの経過時間)を引く
        currentTime -= elapsedTime
        currentTimeLabel.text = "\(currentTime)"
        //再びタイマーを起動
        timer = Timer.scheduledTimer(timeInterval: 1.0, target: self, selector: #selector(advancedTime), userInfo: nil, repeats: true)
    }
```

## 実装結果

ホームボタン押下、スリープ、の順にバックグラウンドへ移行してます。

<img width="400" alt="output.gif" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/284464/0858f0ba-43d5-f423-2120-beb131efb3cb.gif">

## ソースコード

```swift:backgroundTimer.swift
import UIKit

class backgroundTimer: UIViewController,backgroundTimerDelegate {
    
    @IBOutlet weak var currentTimeLabel: UILabel!
    @IBOutlet weak var start: UIButton!
    //タイマー起動中にバックグラウンドに移行したか
    var timerIsBackground = false
    var timer:Timer!
    var currentTime = 15
    
    override func viewDidLoad() {
        super.viewDidLoad()
        //SceneDelegateを取得
        guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
            let sceneDelegate = windowScene.delegate as? SceneDelegate else {
            return
        }
        sceneDelegate.delegate = self
        currentTimeLabel.text = "15"
    }
    
    @IBAction func start(_ sender: Any) {
        timer = Timer.scheduledTimer(timeInterval: 1.0, target: self, selector: #selector(advancedTime), userInfo: nil, repeats: true)
        start.isHidden = true
    }
    
    @objc func advancedTime() {
        if currentTime >= 1 {
            currentTime -= 1
            currentTimeLabel.text = "\(currentTime)"
        } else {
            timer.invalidate()
            start.isHidden = false
            currentTime = 15
            currentTimeLabel.text = "\(currentTime)"
        }
    }
    
    func checkBackground() {
        //バックグラウンドへの移行を確認
        if let _ = timer {
            timerIsBackground = true
        }
    }
    
    func setCurrentTimer(_ elapsedTime:Int) {
        //残り時間から引数(バックグラウンドでの経過時間)を引く
        currentTime -= elapsedTime
        currentTimeLabel.text = "\(currentTime)"
        //再びタイマーを起動
        timer = Timer.scheduledTimer(timeInterval: 1.0, target: self, selector: #selector(advancedTime), userInfo: nil, repeats: true)
    }
    
    func deleteTimer() {
        //起動中のタイマーを破棄
        if let _ = timer {
            timer.invalidate()
        }
    }
    
}
```

```swift:SceneDelegate.swift
import UIKit

//デリゲート用の変数、関数
protocol backgroundTimerDelegate: class {
    func setCurrentTimer(_ elapsedTime:Int)
    func deleteTimer()
    func checkBackground()
    var timerIsBackground:Bool { set get }
}

class SceneDelegate: UIResponder, UIWindowSceneDelegate {

    var window: UIWindow?
    weak var delegate: backgroundTimerDelegate?
    let ud = UserDefaults.standard

    func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
        guard let _ = (scene as? UIWindowScene) else { return }
    }

    //アプリ画面に復帰した時
    func sceneDidBecomeActive(_ scene: UIScene) {
        if delegate?.timerIsBackground == trued {
            let calender = Calendar(identifier: .gregorian)
            let date1 = ud.value(forKey: "date1") as! Date
            let date2 = Date()
            let elapsedTime = calender.dateComponents([.second], from: date1, to: date2).second!
            delegate?.setCurrentTimer(elapsedTime)
        }
    }

    //アプリ画面から離れる時（ホームボタン押下、スリープ）
    func sceneWillResignActive(_ scene: UIScene) {
        ud.set(Date(), forKey: "date1")
        delegate?.checkBackground()
        delegate?.deleteTimer()
    }
}
```

## 参考

[[iOS]バックグラウンドで長時間BLE通信続ける方法](http://harumi.sakura.ne.jp/wordpress/2019/05/23/ios%E3%83%90%E3%83%83%E3%82%AF%E3%82%B0%E3%83%A9%E3%82%A6%E3%83%B3%E3%83%89%E3%81%A7%E9%95%B7%E6%99%82%E9%96%93ble%E9%80%9A%E4%BF%A1%E7%B6%9A%E3%81%91%E3%82%8B%E6%96%B9%E6%B3%95/)
[【Swift 4.2】 アラーム時計の作り方 - Qiita](https://qiita.com/K_Kenty/items/d589a0f0e1949e167aab#uibackgroundtaskidentifier-method)
[[Swift] iOSのバックグラウンド処理について - Qiita](https://qiita.com/teamhimeH/items/51efb4fc68d3f1d2ebb9)
[iOSにおけるバックグラウンド処理の全体感 - Qiita](https://qiita.com/chocoyama/items/61dc07bf3312f62fc565)
[[iOS][小ネタ] アプリのバックグラウンド実行を禁止する方法 | Developers.IO](https://dev.classmethod.jp/articles/iso-application-does-not-run-in-background/ )