• 티스토리 홈
  • 프로필사진
    2료일
  • 방명록
  • 공지사항
  • 태그
  • 블로그 관리
  • 글 작성
2료일
  • 프로필사진
    2료일
    • 분류 전체보기 (118)
      • SWIFT개발 (29)
      • 알고리즘 (25)
      • Design (6)
      • ARkit (1)
      • 면접준비 (32)
      • UIkit (2)
      • Vapor-Server with swift (3)
      • 디자인패턴 (5)
      • 반응형프로그래밍 (12)
  • 방문자 수
    • 전체:
    • 오늘:
    • 어제:
  • 최근 댓글
      등록된 댓글이 없습니다.
    • 최근 공지
        등록된 공지가 없습니다.
      # Home
      # 공지사항
      #
      # 태그
      # 검색결과
      # 방명록
      • CLMonitor
        2024년 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
        다음글
        다음 글이 없습니다.
        이전글
        이전 글이 없습니다.
        댓글
      조회된 결과가 없습니다.
      스킨 업데이트 안내
      현재 이용하고 계신 스킨의 버전보다 더 높은 최신 버전이 감지 되었습니다. 최신버전 스킨 파일을 다운로드 받을 수 있는 페이지로 이동하시겠습니까?
      ("아니오" 를 선택할 시 30일 동안 최신 버전이 감지되어도 모달 창이 표시되지 않습니다.)
      목차
      표시할 목차가 없습니다.
        • 안녕하세요
        • 감사해요
        • 잘있어요

        티스토리툴바