能登 要

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

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

CoreLocationのauthorizationフローをasync/awaitを使って実装する - SwiftUI100行チャレンジ(12)

端的にいうと

CoreLocationの位置情報取得認証フローをasyc/awaitを使って実装する。

1) WeatherKitを使いたい

iOS16 SDKから追加された天気情報を読み取るサービスWeatherKit のWWDC22セッションビデオが公開されていた(12分程度のセッションなので気楽に見ることができる)。

Meet WeatherKit - WWDC22 - Videos - Apple Developer

セッション中で紹介されていたWeatherKitのネイティブライブラリのサンプルコードを使ったサンプルアプリを作ろうとした際、どうせなら現在位置を取得するアプリにしようと思いたつ。

現在位置情報はiOS SDKのCoreLocation を使うことで実現できるが、位置情報に関してはObjective-C利用も前提としたフレームワークなのでSwiftUI などで使うのに最適化されてはいない。各種イベントを受け取るDelegateを受け取る仕組み自体は悪くないがそれでも位置情報の利用許諾を求める処理に関しては並行性(Concurrency)を取り入れた形で使いたいと考えるようになった。

2) サンプルコード

サンプルコードではCoreLocationを使った現在位置取得のためのユーザーユーザー許諾処理を並行性を取り入れている。またユーザー許諾後の位置情報の更新はCombineベースのものを利用する。

サンプルコードは完全なものだがXcode上のプロジェクト - InfoにてPrivacy - Location When In Use Usage Description(NSLocationWhenInUseUsageDescription) に位置情報を利用するためにユーザーに提示する許諾内容を追記する必要がある。

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<string>現在地の天気を取得するために使用します</string>
</plist>

import SwiftUI
import Foundation
import CoreLocation
import Combine

class LocationManager: ObservableObject {
    enum LocationManagerError: Error, LocalizedError {
        case deniedLocation; case restrictedLocation; case unknown
        var errorDescription: String? {
            switch self {
            case .deniedLocation: return "Location information is not allowed. Please allow Settings - Privacy to retrieve the location of your app."
            case .restrictedLocation: return "Location information is not allowed by the constraints specified on the device."
            case .unknown: return "An unknown error has occurred."
            }
        }
    }
    typealias AuthorizationStatusContinuation = CheckedContinuation<CLAuthorizationStatus, Never> // Continuation for asymc/await
    fileprivate class DelegateAdaptorForAuthorization: NSObject, CLLocationManagerDelegate {
        var continuation: AuthorizationStatusContinuation?
        func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
             continuation?.resume(returning: manager.authorizationStatus)
        }
    }
    fileprivate class DelegateAdaptorForLocation: NSObject, CLLocationManagerDelegate {
        var currentLocation: PassthroughSubject<CurrentLocationStatus, Never> = .init()
        func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
            guard let newLocation = locations.last else { return }
            currentLocation.send(CurrentLocationStatus(active: true, location: newLocation))
        }
    }
    fileprivate let delegateAdaptorForLocation: DelegateAdaptorForLocation = .init()
    fileprivate let locationManager: CLLocationManager = .init()
    @Published var currentLocation: CurrentLocationStatus = .init(active: false, location: .init())
    
    struct CurrentLocationStatus: Equatable {
        var active: Bool; var location: CLLocation
    }
    var anyCancelableCollection = Set<AnyCancellable>()

    func startCurrentLocation() async throws {
        let authorizationStatus: CLAuthorizationStatus
        if locationManager.authorizationStatus == .notDetermined {
            let delegateAdaptor = DelegateAdaptorForAuthorization()
            locationManager.delegate = delegateAdaptor
            authorizationStatus = await withCheckedContinuation { (continuation: AuthorizationStatusContinuation) in
                delegateAdaptor.continuation = continuation
                locationManager.requestWhenInUseAuthorization()
            }
        } else {
            authorizationStatus = locationManager.authorizationStatus
        }
        locationManager.delegate = delegateAdaptorForLocation
        delegateAdaptorForLocation.currentLocation.sink { [unowned self] location in
            if self.currentLocation.location.distance(from: location.location) < 5 /*distance 5metre over*/ {
                return
            }
            self.currentLocation = location
        }.store(in: &anyCancelableCollection)
        locationManager.startUpdatingLocation()

        switch authorizationStatus {
            case .notDetermined: break
            case .denied: throw LocationManagerError.deniedLocation
            case .authorizedAlways, .authorizedWhenInUse: break
            case .restricted: throw LocationManagerError.restrictedLocation
            default: throw LocationManagerError.unknown
        }
    }
}

@main
struct SimpleLocationAuthorizationApp: App {
    @StateObject var locationManager: LocationManager = .init()
    var body: some Scene {
        WindowGroup { 
            ContentView(locationManager: locationManager)
        }
    }
}
struct ContentView: View {
    @ObservedObject var locationManager: LocationManager
    @State var errorMessage: String = ""
    @State var showErrorAlert: Bool = false
    var body: some View {
        VStack {
            Image(systemName: "location.circle.fill").imageScale(.large).foregroundColor(.accentColor)
            Text("Obtaining location information...")
        }.task {
            do {
                try await locationManager.startCurrentLocation()
            } catch let error {
                errorMessage = error.localizedDescription
                showErrorAlert = true
            }
        }.alert(errorMessage, isPresented: $showErrorAlert) {
            Button("OK", role: .cancel, action: { errorMessage = ""})
        }
    }
}

3) サンプルコード要点

withCheckedContinuation を使ってasync/await/actor に準拠しない非同期イベントをasync/await/actorに適合させている。withCheckedContinuation はCheckedContinuation を経由して非同期イベントでの処理の完了もしくはエラーが出るまで待機状態となる。

CheckedContinuationはジェネリック型でSDK上では以下の定義となっている。

public struct CheckedContinuation<T, E>

Tは結果型、Eはエラー型で、EがNeverの場合はwithCheckedContinuation() を使用できる。EにErrorの場合はwithCheckedThrowingContinuation() を利用できる。

async/await/actorは前述のCheckedContinuationやwithCheckedContinuation、withCheckedThrowingContinuation以外にも新しいキーワードやメソッド導入されているのキーワードやメソッドに関して慣れておくとasync/await/actor を既存コードに取り込むことができるだろう。

4) 実現できていない機能

サンプルコード上で2点ほど機能の不足がある。

4-1)ユーザー許諾タイミングの不足

サンプルコードではユーザー許諾を起動時のみに見做しているが実際はアプリがバックグラウンドに移行、そのごフォアグラウンドに復帰した際にユーザーの気まぐれで位置情報許諾が無効にされている場合がある。位置情報については一度ユーザー許諾が取れたからと言って安心してはいけない。

4-2)アップデート間隔の抑制

サンプルコードではアップデート頻度を抑制するため現在位置との移動間隔が少ない場合(ソースコード上では5m) はアップデートリクエストを実施していない。移動間隔については徒歩移動を前提としているため自動車や飛行機といった乗り物に乗った場合の条件切り分けが必要となる。CoreLocationが提供しているCLLocation型に乗り物情報が含まれているので移動間隔の判別に役立てることができる。

5) まとめ

位置情報を使うサンプルコードを作る際に現在地取得のためのユーティリティ的なコードがなかったためSwiftUI100行チャレンジとして作成してみた。iPhoneの機能を利用するためのユーザー許諾処理は位置情報以外にも多いので今回のソースコードが実装の参考になるかもしれない。