能登 要

札幌在住のiOSアプリ開発者。SwiftUI により分割されたデバイス間を縦横にやりとりできる考え方に転換しています。

iOSアプリ開発者。2009年のiPhoneアプリ開発開始時期から活動。開発言語のアップデートの中でSwiftUIおよび周辺技術に着目中。

watchOSアプリのバックグラウンド更新 - SwiftUI100行チャレンジ⑩

端的にいうと

watchOSアプリのComplicationsのバックグラウンド更新処理呼び出しのサンプル。

1) watchOSアプリのComplications機能

AppleWatchでは時計盤面上で各種情報を表示できる。各種情報はComplicationsと呼ばれwatchOSアプリで対応することで実装可能となっている。AppleWatchの時計盤面上に情報を表示したい場合は最低でもwatchOSアプリを作る必要があるので開発の敷居はある程度高くなっている。

watchOSConfiguration

2) いがみ合うフレームワーク達

開発しているアプリ充電報告さん(BatteReceiver)ではwatchOSアプリを提供している(2020年〜)がComplicationsのアップデート方法について最適解が出せず苦慮していた。

最適解が出てこない理由としては開発者のコミニュケーション規模がiPhoneアプリ開発者と比べ多いわけではないのでノウハウが共有されない状況がある。

余談だが、Google Firebase iOS SDKではwatchOS6.xよりFirebase Auth、Firebase Cloud Firestore他のサポートを開始している。Google 内でも活発なコミュニティであるFirebase iOS SDKでもwatchOSのサポート順位はそこまで高いわけではない。

解決のためのノウハウが見つからない中で原因を模索したところ、watchOSアプリを構成するフレームワーク(WatchKit, ClockKit, Watch Connectivity)のうち、Bluetoohを介してwatchOS、iPhone間をやりとりするWatch Connectivityフレームワークを使用したデータ通信後、別フレームワークを呼び出しても処理が完了しない事例を確認できた。

根本的な原因ははっきりしていないが、watchOSのデータのやりとりは一旦ファイルとして格納したものを呼び出し側でファイルへのアクセスする方法で回避に成功した。

3) watchOSアプリのバックグラウンド更新

フレームワーク間の呼び出し時に相性的なものが存在し、影響を与えているWatch Connectivityフレームワークを介してのComplications更新を回避するための例としてwatchOSアプリのバックグラウンド更新のコードを記述した。

import SwiftUI

struct RandomFox: Equatable, Codable { let image: String; let link: String }
class ExtensionDelegate: NSObject, ObservableObject, WKExtensionDelegate {
    var foregroundDataTask: URLSessionDataTask?; var backgroundDataTask: URLSessionDataTask?
    var timer: Timer?
    let url = URL(string: "https://randomfox.ca/floof/")!
    @Published var randomFox: RandomFox? = nil
    // utilities
    func taskRandomFox(completionHandler: @escaping (Error?) -> Void) -> URLSessionDataTask { // DataTask create function
        DispatchQueue.main.async { self.randomFox = nil }
        let dataTask = URLSession.shared.dataTask(with: URLRequest( url: url, cachePolicy: .reloadIgnoringLocalCacheData), completionHandler: { [unowned self] jsonData, response, error in
            guard error == nil, let jsonData = jsonData else { completionHandler(error); return }
            DispatchQueue.main.async {
                self.randomFox = try? JSONDecoder().decode(RandomFox.self, from: jsonData)
                completionHandler(nil)
            }
        })
        return dataTask
    }
    func systemDateFormatter(_ dateFormat: String ) -> DateFormatter { // system date formatter for UTC + 00:00:00
        let dateFormatter = DateFormatter();dateFormatter.locale = NSLocale.system; dateFormatter.dateFormat = dateFormat; return dateFormatter
    }
    lazy var dateFormatterMinute: DateFormatter = { return systemDateFormatter("m") }()
    lazy var dateFormatterSecond: DateFormatter = { return systemDateFormatter("ss") }()
    func ceil13MinitesTimeInterval(_ date: Date) -> TimeInterval {
        let ref = Int(dateFormatterMinute.string(from: date)) ?? 0
        let second = Double(dateFormatterSecond.string(from: date)) ?? 0.0
        let diff = 5 - (ref % 5)
        let newDate = date.addingTimeInterval(60 * Double(diff) - second)
        let timeinterval = newDate.timeIntervalSince(date)
        return timeinterval
    }
    func scheduleBackgroundRefreshTasks(_ timeInterval: TimeInterval) {
        let targetDate = Date().addingTimeInterval(timeInterval)
        WKExtension.shared().scheduleBackgroundRefresh(withPreferredDate: targetDate, userInfo: nil) { (error) in
            if let error = error {
                print("*** An background refresh error occurred: \(error.localizedDescription) ***")
                return
            }
        }
    }
    // Extension Delegate methods
    func applicationDidFinishLaunching() {
        foregroundDataTask = taskRandomFox(completionHandler: { _ in })
        foregroundDataTask?.resume()
    }
    func applicationDidBecomeActive() {
        backgroundDataTask = nil
        timer?.invalidate()
        timer = Timer.scheduledTimer(timeInterval: ceil13MinitesTimeInterval(Date()), target: self, selector: #selector(Self.onTimer), userInfo: nil, repeats: false)
    }
    @objc func onTimer() {
        self.timer = Timer.scheduledTimer(timeInterval: ceil13MinitesTimeInterval(Date()), target: self, selector: #selector(Self.onTimer), userInfo: nil, repeats: false)
        foregroundDataTask = taskRandomFox(completionHandler: { _ in })
        foregroundDataTask?.resume()
    }
    func applicationWillResignActive() {
        timer?.invalidate()
        foregroundDataTask = nil
    }
    func applicationDidEnterBackground() {
        backgroundDataTask = nil
        let timeInterval = ceil13MinitesTimeInterval(Date())
        scheduleBackgroundRefreshTasks(timeInterval)
    }
    func handle(_ backgroundTasks: Set<WKRefreshBackgroundTask>) {
        for task in backgroundTasks {
            switch task {
            case let backgroundTask as WKApplicationRefreshBackgroundTask:
                backgroundDataTask = taskRandomFox(completionHandler: { [unowned self] _ in
                    self.scheduleBackgroundRefreshTasks(ceil13MinitesTimeInterval(Date()))
                    backgroundTask.setTaskCompletedWithSnapshot(false)
                    self.backgroundDataTask = nil
                })
                backgroundDataTask?.resume()
            default:
                task.setTaskCompletedWithSnapshot(false)
            }
        }
    }
}

// SwiftUI - content view / app
struct ContentView: View {
    @StateObject var extensionDelegate: ExtensionDelegate
    var body: some View {
        if let randomFox = extensionDelegate.randomFox {
            AsyncImage(url: URL(string: randomFox.image)) { image in
                image.resizable().aspectRatio(nil, contentMode: .fill).ignoresSafeArea()
            } placeholder: { ProgressView() }
        } else { Text("🦊").font(.largeTitle).padding() }
    }
}

@main
struct WatchOSIntervalUpdateApp: App {
    @WKExtensionDelegateAdaptor(ExtensionDelegate.self) var extensionDelegate
    var body: some Scene { WindowGroup { ContentView(extensionDelegate: extensionDelegate)} }
}

サンプルコードではwatchOSアプリがバックグラウンドに入る(アプリが背面に回る)タイミングで一定間隔で呼び出されるバックグラウンドタスクを登録、バックグラウンド呼び出し中にREST APIの呼び出す例となっている。サンプルコードではランダムに狐が映った写真のURLをランダムに返すAPIを呼び出し、watchOSの画面に狐の画像を表示するサンプルとなっている。

Complicationsを更新する際は、reloadTimeline を呼び出す。

for complication in CLKComplicationServer.sharedInstance().activeComplications {
    CLKComplicationServer.sharedInstance().reloadTimeline(for: complication)
}

実際にwatchOSアプリのComplicationの更新を行う際の現実的な更新頻度としては15分程度で呼び出す。これはwatchOS上のComplicationのアップデート頻度はOS側で限定されていることに由来する。

ComplicationUpdateInterval

まとめ

AppleWatchはガジェット感が強く、時計盤の上に情報を表示できるComplicationsは両手が塞がる場面が多いユーザー、仕事上スマートフォンを確認できない職種に就いているユーザーにとって腕を持ち上げるだけで情報を確認できる貴重な情報源となる。

一方で実装面で言うと開発者が少ないため情報共有が進展しない、サポートしているサードパーティ製SDKが少ないなど開発を取り巻く状況は良いとは言えない。

watchOSアプリはiPhoneアプリよりも開発には厳しい環境なのは否めないが、2022年以降Appleが出す新デバイスが出た時に開発環境を想定してwatchOSアプリ開発に取り組んでも面白いだろう。