SWIFT개발

CLMonitor

2료일 2024. 10. 22. 10: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을 통해 테스크 취소와 리소스 정리를 통해 메모리 누수를 방지할수 있었습니다