- CLMonitor2024년 10월 22일
- 2료일
- 작성자
- 2024.10.22.:05
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을 통해 테스크 취소와 리소스 정리를 통해 메모리 누수를 방지할수 있었습니다
'SWIFT개발' 카테고리의 다른 글
ShareLink - 개발일기 (1) 2024.11.09 TCA- TestingCode (1) 2024.10.26 TCA-Dependency...DI,DIP를 곁들인 (2) 2024.09.20 TCA-3번째시간 Dependency (2) 2024.07.19 TCA(2)-Store, ViewStore& Binding (4) 2024.07.16 다음글이전글이전 글이 없습니다.댓글