CLMonitor
WWDC23에서 새로 나온 API로 사용자의 위치나 비컨을 모니터링하는 새로운 방식이다. 이 글에서는 실제 내가 볼레또 프로젝트에 어떻게 적용했는지를 자연스럽게 작성할 예정입니다.
사실상 너무 간단하다.
1. 원하는 이름으로 CLMonitor 인스턴스를 생성하고
2. 모니터링할 조건(지리적위치, 비컨)을 정의하고
3. 이벤트대기: 조건이 충족되면 이벤트를 비동기적으로 수신하고
4. 동작 수행: 이벤트가 발생하면 원하는 로직을 실행한다.
즉 조건에 의해 조건이 충족될때 이벤트를 비동기적으로 처리할 수 잇게 해준다.
하지만 어떻게 구현되어있는지를 살펴보자
CLMonitor 인스턴스는 각각 하나의 모니터링 작업에 대한 "게이트웨이" 역할을 한다. CLMonitor는 액터로서 설계되었기 때문에, 여러 스레드가 동시에 같은 인스턴스에 접근하려 할 때도 액터 자체가 이들을 직렬화하여 안전하게 처리할 수 있다. 이로 인해 추가적인 동기화 메커니즘이나 스레드 관리에 신경 쓸 필요가 없어지며, 동시성에 대한 걱정 없이 비동기적으로 작업을 수행할 수 있다.
-> 그래서 위에서 본 것처럼 추가하거나 제거할때 await을 써줘야합니다.
모니터 인스턴스를 맨위의 코드처럼 반환할때 기존에 있으면 그것을 반환하고 아니면 생성해서 가져온다.
이제 두번째줄을 살펴보자
조건을 걸어주고 식별자와 연동할 수 있다. 여기서 Work가 식별하는 것은 사용자가 근무 중일 때만 충족되는 조건의 기록이라고 한다.
결국 모니터링할 기준을 정의하고(조건), 그 조건이 충족하거나 변경하면 이벤트를 방출해준다.
조건에는 두가지가 있다. 해당 지역중심을 등록하는 방식과 2. BeaconIdentityCondition인데 비컨은 개인프로젝트에서 활용안할거같아 생략할 계획이다.
CircularGeographicCondition: 중심(조건의 지리적위치)+반경
func startMonitoring(spot: SpotType) async throws -> AsyncStream<MonitorEvent> {
let stream = AsyncStream<MonitorEvent> { continuation in
activeContinuation = continuation
backgroundSession = CLBackgroundActivitySession()
let task = Task {
monitor = await CLMonitor(spot.spot.upperString)
let frameCondition = CLMonitor.CircularGeographicCondition(
center: spot.spot.coordinate,
radius: 3000.0
)
await monitor?.add(frameCondition, identifier: "Frame")
for landmark in spot.spot.landmarks {
let badgeCenter = CLLocationCoordinate2D(
latitude: landmark.latitude,
longitude: landmark.longtitude
)
let landmarkCondition = CLMonitor.CircularGeographicCondition(
center: badgeCenter,
radius: 1000.0
)
await monitor?.add(landmarkCondition, identifier: landmark.badgetype.rawValue)
}
자 내 코드를 보면서 알아보자. 간단하게 설명하면 내가 만약 부산여행을 간다고 하면 부산역에서는 역에 대한 이벤트를 받고 싶고, 해운대에 갔을때는 명소에 대한 이벤트를 받고 싶었다.
먼저 부산이라는 CLMonitor 인스턴스를 만들어주었다. -> 그 후 지리적 위치를 기반으로 동작하는 CircularGeographicCondition에 중심과 반경을 등록하였다. 그 후 인스턴스에 추가해주었다. 이벤트를 구분하는 것으로는 identifier를 사용하였다.
추가해줄때 assuming = 초기에 이 위치가 아니다 미 만족상태에서 시작한다. 상태 추정이 잘못되었어도 corelocaiton이 정정해주기에 걱정 안해도 된다.
그 후 부산역은 하나지만 명소는 여러개이므로 해당 지역들을 위도와 경도를 추가하여 해당 명소이름을 identifier로 monitor에 추가해줬습니당. 그러면 이제 어떻게 이벤트를 처리하는지 이어서 코드로 볼게요
for try await event in events {
switch event.state {
case .satisfied:
if event.identifier == "Frame" {
await monitor?.remove("Frame")
continuation.yield(.didEnterFrameRegion(spot.spot.name))
} else if let badgeType = StickerCodes(rawValue: event.identifier) {
await monitor?.remove(event.identifier)
continuation.yield(.didEnterBadgeRegion(badgeType))
}
default:
break
}
}
사용자가 등록한 지역내에 들어가면 identifier로 이벤트를 구분하여 해당하는 맞는 커스텀이벤트를 방출해주도록 구현했습니다.
AsyncStream을 통해 이벤트를 스트리밍하며, 조건 충족 시 실시간으로 반응합니다.
뭐 이런식으로 해당 지역을 나갈때도 이벤트 처리를 해줄수있지만 이건 불필요해서 ㅎㅎ
기존 방식과 차이점
사실 이전에도 Geofencing기반으로 위치 기반 서비스를 구현할 수 있었습니다. 하지만 단점이 있었기에 새로운게 나왓겟죠!!
1. 제한된 유연성: 모니터링 조건이 아마 20개까지 등록할 수 있었기에 확장과 유연성에 한계가 있었습니다. 단적인 제 프로젝트에서의 예시입니다. 실제로는 국내 지역전부를 목표로 하고 있기에 더 많아지겟죠.
2. 동시성 관리: CLMonitor가 액터 모델이여서 동시성을 관리해주지만 기존은 개발자가 직접 관리를 해야해서 안전성 이슈가 있엇습니다.
3. 백그라운드 처리: 기존의 방식은 지속적으로 위치 추적을 하여 세밀한 위치 추적하거나 다양한 이벤트를 커스텀한다면 좋을 수 있다. 하지만 내 앱에서는 단순 진입/이탈 감지가 필요하다. 그래서 시스템 수준에서 이벤트를 관리하고 조건이 만족될때만 이벤트를 발생시키므로 불필요한 위치업데이트를 줄여 배터리 효율이 더 좋은 CLMonitor를 사용하였다. 즉 iOS 시스템이 직접 조건을 모니터링하여 앱이 백그라운드에 있어도 시스템이 이벤트를 감지하여 기존의 주기적인 위치 업데이트를 받는것보다 배터리 소모를 줄일 수 있었다.
import ComposableArchitecture
import CoreLocation
import SwiftUI
@DependencyClient
struct LocationClient {
var authorizationStatus: @Sendable () async -> CLAuthorizationStatus = {.denied}
var requestauthorzizationStatus: @Sendable () async -> Void
var startMonitoring: @Sendable (SpotType) async throws -> AsyncStream<MonitorEvent>
var stopMonitoring: @Sendable () async -> Void
var disableLocationServices: @Sendable () -> Void
var isMonitoringActive: @Sendable () async -> Bool = { false }
@CasePathable
enum MonitorEvent: Equatable {
case didEnterFrameRegion(String)
case didEnterBadgeRegion(StickerCodes)
}
}
extension LocationClient: DependencyKey {
static let liveValue: Self = {
return Self(
authorizationStatus: {
LocationActor.shared.authorizationStatus()
}, requestauthorzizationStatus: {
LocationActor.shared.requestAuthorizationStatus()
},
startMonitoring: {spot in
try await LocationActor.shared.startMonitoring(spot: spot)
},
stopMonitoring: {
LocationActor.shared.stopMonitoring()
}, disableLocationServices: {
guard let appSettingsURL = URL(string: UIApplication.openSettingsURLString) else { return }
DispatchQueue.main.async {
UIApplication.shared.open(appSettingsURL)
}
}, isMonitoringActive: {
LocationActor.shared.isMonitoring()
}
)
@globalActor final actor LocationActor {
static let shared = LocationActor()
private var delegate = Delegate()
private var monitor: CLMonitor?
private var backgroundSession: CLBackgroundActivitySession?
private var activeContinuation: AsyncStream<MonitorEvent>.Continuation?
private final class Delegate: NSObject, @unchecked Sendable, CLLocationManagerDelegate {
let manager = CLLocationManager()
var authorizationContinuation: CheckedContinuation<CLAuthorizationStatus, Never>?
override init() {
super.init()
manager.delegate = self
manager.desiredAccuracy = kCLLocationAccuracyKilometer
manager.pausesLocationUpdatesAutomatically = false
manager.allowsBackgroundLocationUpdates = true
}
nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
guard let continuation = authorizationContinuation else { return }
authorizationContinuation = nil
}
}
func isMonitoring() -> Bool {
guard let monitor = monitor else {return false}
return true
}
func authorizationStatus() -> CLAuthorizationStatus {
delegate.manager.authorizationStatus
}
func requestAuthorizationStatus() {
delegate.manager.requestAlwaysAuthorization()
}
func startMonitoring(spot: SpotType) async throws -> AsyncStream<MonitorEvent> {
let stream = AsyncStream<MonitorEvent> { continuation in
activeContinuation = continuation
backgroundSession = CLBackgroundActivitySession()
let task = Task {
monitor = await CLMonitor(spot.spot.upperString)
let frameCondition = CLMonitor.CircularGeographicCondition(
center: spot.spot.coordinate,
radius: 3000.0
)
await monitor?.add(frameCondition, identifier: "Frame")
for landmark in spot.spot.landmarks {
let badgeCenter = CLLocationCoordinate2D(
latitude: landmark.latitude,
longitude: landmark.longtitude
)
let landmarkCondition = CLMonitor.CircularGeographicCondition(
center: badgeCenter,
radius: 1000.0
)
await monitor?.add(landmarkCondition, identifier: landmark.badgetype.rawValue)
}
guard let events = await monitor?.events else {
continuation.finish()
return
}
for try await event in events {
switch event.state {
case .satisfied:
if event.identifier == "Frame" {
await monitor?.remove("Frame")
continuation.yield(.didEnterFrameRegion(spot.spot.name))
} else if let badgeType = StickerCodes(rawValue: event.identifier) {
await monitor?.remove(event.identifier)
continuation.yield(.didEnterBadgeRegion(badgeType))
}
default:
break
}
}
}
continuation.onTermination = { _ in
task.cancel()
}
}
return stream
}
func stopMonitoring() {
backgroundSession?.invalidate()
backgroundSession = nil
activeContinuation?.finish()
activeContinuation = nil
monitor = nil
}
}
}()
}
extension LocationClient: TestDependencyKey {
static let testValue = Self()
}
extension DependencyValues {
var locationClient: LocationClient {
get { self[LocationClient.self] }
set { self[LocationClient.self] = newValue }
}
}
//
// LocationMointoringFeature.swift
// Boleto
//
// Created by Sunho on 10/23/24.
//
import Foundation
import ComposableArchitecture
import UIKit
enum LocationMonitoringError: Error, Equatable {
case monitoringStartFailed
case notificationFailed
case ticketValidationFailed
case authorizedFailed
var errorDescription: String? {
switch self {
case .monitoringStartFailed:
return "Failed to start location monitoring"
case .notificationFailed:
return "Failed to schedule notification"
case .ticketValidationFailed:
return "Failed to validate ticket dates"
case .authorizedFailed:
return "Failed to access location"
}
}
}
@Reducer
struct LocationMointoringFeature {
@ObservableState
struct State: Equatable {
var lastEvent: LocationClient.MonitorEvent?
var error: LocationMonitoringError?
}
enum Action: Equatable {
case checkMonitoring(SpotType)
case startMonitoring(SpotType)
case stopMonitoring
case monitoringEvent(LocationClient.MonitorEvent)
case monitorFailed(LocationMonitoringError)
}
@Dependency(\.locationClient) var locationClient
@Dependency(\.notificationClient) var notificationClient
@Dependency(\.alarmClient) var alarmClient
@Dependency(\.userClient) var userclient
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .checkMonitoring(let spot):
return .run { send in
let isMonitoring = await locationClient.isMonitoringActive()
if isMonitoring {
return
}
let authorizationStatus = await locationClient.authorizationStatus()
switch authorizationStatus {
case .notDetermined, .authorizedWhenInUse:
await locationClient.requestauthorzizationStatus()
await send(.startMonitoring(spot))
case .denied, .restricted:
locationClient.disableLocationServices()
case .authorizedAlways:
await send(.startMonitoring(spot))
@unknown default:
await send(.monitorFailed(.monitoringStartFailed))
}
}
case .startMonitoring(let spot):
return .run { send in
do {
let stream = try await locationClient.startMonitoring(spot)
for try await event in stream {
await send(.monitoringEvent(event))
}
} catch {
await send(.monitorFailed(.monitoringStartFailed))
}
}
case .stopMonitoring:
return .run {send in
await locationClient.stopMonitoring()
}
case .monitoringEvent(let event):
state.lastEvent = event
return .run { send in
do {
switch event {
case .didEnterBadgeRegion(let image):
try await notificationClient.add(BadgeNotification(id: image.rawValue, stickerImageType: image))
try await alarmClient.postNewAlarm(.sticker , image.koreanString)
try await userclient.postStickerCode(image.rawValue)
case .didEnterFrameRegion(let spotname):
try await notificationClient.add(FrameNotification(id: spotname))
try await alarmClient.postNewAlarm(.regionActive , spotname)
}
}catch {
await send(.monitorFailed(.notificationFailed))
}
}
case .monitorFailed(let error):
state.error = error
return .none
}
}
}
}
내가 어떻게 위치 서비스관련 로직을 설계했는지 살펴보자.
StartMonitoring을 통해 지역을 넣으면 AsyncStream<MonitorEvent>를 반환하고 있다. 이는 해당 이벤트를 스트림 형태로 제공해준다.
Feature에서는 스트림을 받아 스트림에서 이벤트를 비동기적으로 수신한다. 그리고 수신한 이벤트를 monitorEvent에서 이벤트 종료에 따라 프레임을 저장하거나 스티커를 저장하고 로컬 노티 푸시를 해주었다.
- AsyncStream: 비동기적으로 발생하는 값의 시퀀스를 생성하고 소비 할 수 있게 해준다. 여기서 소비자(?사용자?)는 for await 구문을 통해 값을 기다린다. AsyncStream 내부적으로 Continuation 객체를 사용하여 값을 yield해주거나 종료한다.
- CLMonitor는 사용자의 위치에 따라 언제 발생할지 예측 불가능한 이벤트기에 AsyncStream을 통해 비동기 이벤트를 순차적으로 처리하고자했습니다.
- AsyncStream의 onTermination을 통해 테스크 취소와 리소스 정리를 통해 메모리 누수를 방지할수 있었습니다