【Swift 4.2】 アラーム時計の作り方

## はじめに

こんにちは！Life is Tech ! iPhoneメンターのKentyです。  
Life is Tech ! メンターによるAdvent Calendarのトップバッターを務めさせていただきます！しかも、今年からは公式開催！今日から２５日まで他分野で活躍するメンターの記事が読めると思うと楽しみです！記念すべき初日は、Swift でアラーム時計の作り方を伝授していきます。簡単につくれるような感じはしますがちょっとした落とし穴があるので解決方法、乗り越え方を丁寧にこの記事では解説していきます。  
今回製作するアプリではユーザーが`UIDatePicker`を使用して起こしてほしい時間を指定することでアラーム(目覚まし)をセットできるようにします。アラームまでの時間は現在の時刻を表示させます。至ってシンプルなアラーム時計です。また今回は[MVCアーキテクチャ](https://developer.apple.com/library/archive/documentation/General/Conceptual/DevPedia-CocoaCore/MVC.html)に基づいて実装していきます。

(アプリの動作確認は必ず**実機**で行ってください)

## Swiftファイル
- Alarm.swift
- CurrentTime.swift
- SetViewController.swift
- SleepingViewController.swift

## 画面構成
<img width="872" alt="Screen Shot 2018-11-27 at 14.30.42.png" src="https://qiita-image-store.s3.amazonaws.com/0/318176/01d2c0aa-1475-87f3-4510-fed27a75dae6.png">


## 関連付け
`SetViewController.swift`では時間を設定する`UIDatePicker`とアラーム時計をスタートするための`UIButton`の関連付けを行います。`viewDidLoad()`で[PickerMode](https://developer.apple.com/documentation/uikit/uidatepicker/mode/time)を[.time](https://developer.apple.com/documentation/uikit/uidatepicker/mode)にすることによって時間のみ選択可能にすることができます。また[.setDate](https://developer.apple.com/documentation/uikit/uidatepicker/1615971-setdate)で現在の時間を表示させます。


`SetViewController.swift`

```swift
//インスタンスを生成
let alarm = Alarm()

@IBOutlet var sleepTimePicker: UIDatePicker!

override func viewDidLoad() {
    super.viewDidLoad()
    //UIDatePickerを.timeモードにする
    sleepTimePicker.datePickerMode = UIDatePicker.Mode.time
    //現在の時間をDatePickerに表示
    sleepTimePicker.setDate(Date(), animated: false)
}

@IBAction func alarmBtnWasPressed(_ sender: UIButton) {

}
```

`SleepingViewController.swift` では現在の時間が表示される`UILabel`と`SetViewController`に戻るための`UIButton`の関連付けを行います。[dismiss](https://developer.apple.com/documentation/uikit/uiviewcontroller/1621505-dismiss)で戻るようにします。

`SleepingViewController.swift`

```swift
class SleepingViewController: UIViewController {
    //インスタンスを生成
    var currentTime = CurrentTime()

    @IBOutlet var timeLabel: UILabel!

    override func viewDidLoad() {
    }

    @IBAction func closeBtnWasPressed(_ sender: UIButton) {
        //前のViewControllerに戻る
        dismiss(animated: true, completion: nil)
    }
}
```

## 現在の時間の取得と表示　　　
現在の時間を取得して`DataFormatter`での文字列化を行います。[イニシャライザ](https://docs.swift.org/swift-book/LanguageGuide/Initialization.html)では[.scheduledTimer](https://developer.apple.com/documentation/foundation/timer/1412416-scheduledtimer)でタイマーを作成し、1秒間ごとに`updateCurrentTime()`を呼ばせるようにする。`updateCurrentTime()`では [.dateFormat](https://developer.apple.com/documentation/foundation/dateformatter/1413514-dateformat)で文字列化の形式を指定、[.timeZone](https://developer.apple.com/documentation/foundation/dateformatter/1411406-timezone)でデバイスに設定されている時間帯を適応しています。日付の取得は`Date()`を用いる。

`CurrentTime.swift`

```swift
class CurrentTime{

    var timer: Timer?
    var currentTime: String?
    var df = DateFormatter()
    weak var delegate: SleepingViewController?

    init() {
        if timer == nil{
            //タイマーをセット、一秒ごとにupdateCurrentTimeを呼ぶ
            timer = Timer.scheduledTimer(timeInterval: 1, target: self, selector: #selector(updateCurrentTime), userInfo: nil, repeats: true)
        }
    }

    @objc private func updateCurrentTime(){
        //フォーマットの指定
        df.dateFormat = "HH:mm"
        //時刻をUNIXから端末のタイムゾーンにする
        df.timeZone = TimeZone.current
        //現在の時間をフォーマットに従って文字列化を行う
        let timezoneDate = df.string(from: Date())
        currentTime = timezoneDate
        delegate?.updateTime(currentTime!)
    }
}
```

取得した日付を表示するために`SleepingViewController.swift`に以下のメソッドを書きます。   
`SleepingViewController.swift`

```swift
func updateTime(_ time:String) {
    timeLabel.text = time
}
```

## アラーム機能 / タイマー
###値の引き渡しと画面移動
まずユーザーが`UIDatePicker`を使用して指定した時間を取得し、`selectedWakeUpTime`に代入。また`runTimer()`を呼び[performSegue](https://developer.apple.com/documentation/uikit/uiviewcontroller/1621413-performsegue)で`SleepingViewController`への画面移動も行います。  

`SetViewController.swift`

```swift
@IBAction func alarmBtnWasPressed(_ sender: UIButton) {
    //AlarmにあるselectedWakeUpTimeにユーザーの入力した日付を代入
    alarm.selectedWakeUpTime = sleepTimePicker.date
    //AlarmのrunTimerを呼ぶ
    alarm.runTimer()
    //SleepingViewControllerへの画面移動
    performSegue(withIdentifier: "setToSleeping", sender: nil)
}
```
###アラーム機能
アラーム機能の処理を`Alarm`で行います。ユーザーが設定した時刻までタイマーでカウントダウンを行うようにします。`calculateInterval()`でカウントダウンに必要な秒数を計算したあと`seconds`に代入します。`updateTimer()`が一秒に一回呼ばれることによってsecondsを減らしていきます。`seconds`はユーザーが設定した時刻 - 現在の時間 =　秒数　になります。これを簡単に行えるのが[.timeIntervalSinceNow](https://developer.apple.com/documentation/foundation/nsdate/1407937-timeintervalsincenow)。

####落とし穴 ① 翌日の時間を設定できない問題
このままでは翌日の時刻を選択したときアラームで使える正しい値が返ってきません。なぜなら日をまたいだ時刻を入れると過去の時間として認識されるためです。実際に[.timeIntervalSinceNow](https://developer.apple.com/documentation/foundation/nsdate/1407937-timeintervalsincenow) のドキュメンテーションを読むと "If the date object is earlier than the current date and time, this property’s value is negative."と書いてあります。例えば現在の時刻が22:00でユーザーが入力した時刻が翌朝07:00の場合[.timeIntervalSinceNow](https://developer.apple.com/documentation/foundation/nsdate/1407937-timeintervalsincenow)を使用すると07:00は過去の時間として認識されるため-54,000(秒)と返ってきます。アラームのタイマーで使うには正しい9時間分の秒数32,000(秒)が必要になります。解決策は簡単です。もしintervalがマイナス値の場合 一日秒数 - [.timeIntervalSinceNow](https://developer.apple.com/documentation/foundation/nsdate/1407937-timeintervalsincenow) で計算した値をプラス値にして引き算をすれば可能になります。

####落とし穴 ② intervalの計算にズレがある
ここで実行してみるとintervalの計算にズレがあることに気づきます。現在の時間が22:00:00でユーザーが入力した時間が22:01の場合はintervalが60秒になるはずなのに対してintervalは52秒などズレが生じます。これは`SetViewController.swift`で行っている`sleepTimePicker.setDate(Date(), animated: false)`が影響しています。[.setDate()](https://developer.apple.com/documentation/uikit/uidatepicker/1615971-setdate)をしている`SetViewController`が読み込まれた日付(例: 2018/12/01 22:00:30)が`UIDatePicker`に渡されています。しかし[PickerMode](https://developer.apple.com/documentation/uikit/uidatepicker/1615997-datepickermode)を[.time](https://developer.apple.com/documentation/uikit/uidatepicker/mode)にしているため時刻しかユーザー側では変えられないためアラームをセットした時上記の例の場合は　2018/12/01 ユーザーが設定した時刻:ユーザーが設定した時刻:30が`selectedWakeUpTime`に代入されています。そのため30秒のズレが生じます。

例:　`SetViewController`が読み込まれた時刻が2018/12/01 22:00:30、ユーザーが設定した時刻が30秒後の2018/12/01 22:01:30、[.timeIntervalSinceNow](https://developer.apple.com/documentation/foundation/nsdate/1407937-timeintervalsincenow) が実行された時の時刻が2018/12/01 22:00:40、だった場合20秒ではなく2018/12/01 22:00:40 - 2018/12/01 22:01:30 = 50(秒)になります。

この問題も解決策は簡単で`selectedWakeUpTime`の秒数を全体に引き算すればズレをなくすことができます。上記の例だと2018/12/01 22:00:40 - 2018/12/01 22:01:30 = 50(秒) - 30(秒) = 20(秒)になります。

`Alarm.swift`

```swift
class Alarm{
    var selectedWakeUpTime:Date?
    var audioPlayer: AVAudioPlayer!
    var sleepTimer: Timer?
    var seconds = 0

    //アラーム/タイマーを開始
    func runTimer(){
        //calculateIntervalにユーザーが入力した日付を渡す、返り値をsecondsに代入
        seconds = calculateInterval(userAwakeTime: selectedWakeUpTime!)
        
        if sleepTimer == nil{
             //タイマーをセット、一秒ごとにupdateCurrentTimeを呼ぶ
            sleepTimer = Timer.scheduledTimer(timeInterval: 1, target: self, selector: #selector(updateTimer), userInfo: nil, repeats: true)
        }
    }

    //一秒ごとにsleepTimerに呼ばれる    
    @objc private func updateTimer(){
        if seconds != 0{
            //secondsから-1する
            seconds -= 1
        }else{
            //タイマーを止める
            sleepTimer?.invalidate()
            //タイマーにnil代入
            sleepTimer = nil
         //TODO:音を鳴らす
        }
    }

    //起きる時間までの秒数を計算
    private func calculateInterval(userAwakeTime:Date)-> Int{
        //タイマーの時間を計算する
        var interval = Int(userAwakeTime.timeIntervalSinceNow)

        if interval < 0{
            //落とし穴 ②の解決策
            interval = 86400 - (0 - interval)
        }
        //落とし穴 ③の解決策
        let calendar =  Calendar.current
        let seconds = calendar.component(.second, from: userAwakeTime)
        return interval - seconds
    }
}
```

## 音を鳴らす
音を鳴らす処理を`else`の中に書いていきます。
`Alarm.swift`

```swift
          //secondsが0じゃない場合
         if seconds != 0{
            //secondsから-1する
            seconds -= 1
        }else{
            //タイマーを止める
            sleepTimer?.invalidate()
            //タイマーにnil代入
            sleepTimer = nil
            //音源のパス
            let soundFilePath = Bundle.main.path(forResource: "", ofType: "")!
            //パスのURL
            let sound:URL = URL(fileURLWithPath: soundFilePath)
            do {
                //AVAudioPlayerを作成
                audioPlayer = try AVAudioPlayer(contentsOf: sound, fileTypeHint:nil)
            } catch {
                print("Could not load file")
            }
            //再生
            audioPlayer.play()
        }
    }
```

## アラームを止める

アラームを止める処理も`Alarm`に書いていきます。
`Alarm.swift`

```swift
    func stopTimer(){
         //sleepTimerがnilじゃない場合
        if sleepTimer != nil {
           //タイマーを止める
            sleepTimer?.invalidate()
            //タイマーにnil代入
            sleepTimer = nil
        }else{
            //タイマーを止める
            audioPlayer.stop()
        }
    }
```
`SetViewController.swift`のviewDidAppearで呼びます。
`SetViewController.swift`

```swift
    override func viewDidAppear(_ animated: Bool) {
         //AlarmでsleepTimerがnilじゃない場合
        if alarm.sleepTimer != nil{
            //再生されているタイマーを止める
            alarm.stopTimer()
        }
    }
```

## 最大の落とし穴
####落とし穴 ④ タイマーが止まる問題

ここまでのコードでアラーム時計は作動します。しかし！デバイスをロックしたりホーム画面に戻った場合アラームが作動しないことが判明します。この問題でこの記事にたどり着いた方も多いのではないでしょうか？原因はBackgroundにアプリが移されるとタイマーが止まってしまうからです。iOSにおいてのForeground vs Backgroundの説明は[こちら](https://developer.apple.com/library/archive/documentation/iPhone/Conceptual/iPhoneOSProgrammingGuide/TheAppLifeCycle/TheAppLifeCycle.html#//apple_ref/doc/uid/TP40007072-CH2-SW1)をご覧ください。
解決方法は多数存在します。ここからは[Building an Alarm app on iOS - AMBlog](http://andrewmarinov.com/building-an-alarm-app-on-ios/)に基づいて解説していきます。

##### UIBackgroundTaskIdentifier method
[`UIBackgroundTaskIdentifier`](https://developer.apple.com/documentation/uikit/uibackgroundtaskidentifier)を使用してバックグラウンドでtimerが作動させる方法があります。しかし、[`UIBackgroundTaskIdentifier`](https://developer.apple.com/documentation/uikit/uibackgroundtaskidentifier)は１０分間の猶予しか与えてくれないので１０分以内のタイマーなら作動しますがアラーム時計には残念ながら向かないです。
メリット: タイマーをバックグラウンドで作動できる
デメリット: 10分後に停止される

##### Microphone method 
サイレントな音を録音してアプリをバックグラウンドでも処理を続かせるようにする方法があります。この方法はユーザーのマイクロフォン許可がなくても可能ですがホーム画面のステータスバーが録音中の赤いバーになるため「盗聴されている」というユーザーの不安を煽ることになるためいい方法ではありません。
メリット: 音を鳴らせる
デメリット:　赤いバーの存在でUX的に良くない

##### Push notifications method
[`Local Notification`](https://developer.apple.com/documentation/usernotifications/scheduling_a_notification_locally_from_your_app) を時間にセットすればノーティフィケーションを利用して音を鳴らすことができます。しかし、"Sound files must be less than 30 seconds in length. If the sound file is longer than 30 seconds, the system plays the default sound instead."と[UNNotificationSound](https://developer.apple.com/documentation/usernotifications/unnotificationsound)に書いてあるように３０秒間しか流せません。そのためこの方法はあまり適していないません。
メリット:音を鳴らせる
デメリット:３０秒しか鳴らせない

##### Never sleep method 
今回はこの方法で解決します。AppleがiOS 4 を発表した時、マルチタスク機能を搭載しました。それ以前はアプリを閉じてしまうと寸座にkillされる仕様でした。デベロッパーがこれにスムーズに対応できるようアップルはマルチタスクからオプトアウトできる[UIApplicationExitsOnSuspend](https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/iPhoneOSKeys.html#//apple_ref/doc/uid/TP40009252-SW23)を発表しました。ここでアラーム時計を作るのになぜオプトアウトしないといけないのか疑問になった方いたと思います。iOS 4より以前はアプリを起動した時に端末をロックした場合でも処理が続きます。しかしマルチタスク機能が登場してからこの仕様が変更され、アプリを起動してロックした場合アプリがsuspend状態になります。[UIApplicationExitsOnSuspend](https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/iPhoneOSKeys.html#//apple_ref/doc/uid/TP40009252-SW23)をYESにさせればiOS4以前の仕様が適応され処理をロック状態でもできることになります。そのかわりホーム画面に戻るとアプリがバックグラウンドに移らずkillされます。
メリット：音を鳴らせない
デメリット：ホームに戻るとアプリが即座にkillされる

Never Sleep Method を使用して解決します。info.plist で `Application does not run in background`を`YES`にすれば`UIApplicationExitsOnSuspend`が`true`になります。後はアラームをセットするときにユーザーにホームに戻らないように通知するだけです。今回の記事では割愛しますがもしデメリットである「ホームに戻るとkill」が気になる場合はkillされる前に必要なデータなどを`UserDefaults`に保存してアプリ起動時に読み込むようにすれば問題ないと思います。また引用元のアンドリュー氏によれば上記の方法を組み合わせたりことによって安定性が向上すると示唆している。

####落とし穴 ⑤ それでも音がならない問題
このままではタイマーは作動しますが音が鳴りません。`AVAudioSession`はSingletonであるためバックグラウンドで再生する場合、[.sharedInstance()](https://developer.apple.com/documentation/avfoundation/avaudiosession/1616504-sharedinstance)を使います。

`Alarm.swift`

```swift
    do {
               //AVAudioPlayerを作成
                audioPlayer = try AVAudioPlayer(contentsOf: sound, fileTypeHint:nil)
                // バックグラウンドでもオーディオ再生可能にする
                try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: [])
                try AVAudioSession.sharedInstance().setActive(true)
                
         } catch {
                print("Could not load file")
            }
             //再生
            audioPlayer.play()
        }

```

## まとめ
<img width="872" alt="Screen Shot 2018-11-27 at 14.30.42.png" src="https://qiita-image-store.s3.amazonaws.com/0/318176/01d2c0aa-1475-87f3-4510-fed27a75dae6.png">
Swiftでアラーム時計を製作しました。一見簡単そうではありますが、アラーム時計を作るのは意外と難しいことがわかったと思います。ぜひ参考にしてみてください。
[Swiftファイル](https://tinyurl.com/yao8akvk)