일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | |||||
3 | 4 | 5 | 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 | 18 | 19 | 20 | 21 | 22 | 23 |
24 | 25 | 26 | 27 | 28 | 29 | 30 |
- privacyinfo.plist
- Android
- 360도 뷰어
- ios
- React Native
- 리액트
- native
- 스플래시스크린
- requirenativecomponent
- Native Module
- panorama view
- react-native-fast-image
- 스켈레톤 UI
- 리액트 네이티브
- 스켈레톤 통합
- launchscreen
- React-Native
- 360도 이미지 뷰어
- 360도 이미지
- 네이티브
- 라이브러리 없이
- 리엑트 네이티브
- launch screen
- react
- 3b52.1
- Skeleton UI
- boilerplate 제거
- 앱 성능 개선
- Privacy manifest
- 파노라마 뷰
- Today
- Total
Neoself의 기술 블로그
WCSession 사용기 - 아키텍처 구성 본문
iOS와 WatchOS는 Widget Extension과 달리 완전 독립적인 OS로서 취급되기 때문에, Data Store를 공유하고 있지 않습니다. 때문에, 애플에서는 두 OS의 Data Store들 사이에서의 데이터 동기화 및 통신을 위해 WatchConnectivity 프레임워크를 제공하고 있습니다.
1. Data Store
여기서 Data Store란 무엇일까요??
Data Store는 앱에서 데이터를 저장하고 관리하는 방식을 의미합니다. iOS와 WatchOS에서 사용하는 Data Store 옵션들은 다음과 같습니다.
- UserDefaults: 간단한 키-값 쌍의 데이터를 저장합니다.
- Core Data: 복잡한 데이터 모델을 관리하는 데에 사용됩니다.
- File System: 파일 형태로 데이터를 저장합니다.
*여기서 Core Data의 경우, 더 간단한 구조의 Swift Data가 iOS17부터 지원되고 있기에, 시간이 지남에 따라 대체될 것 같네요...!
2.WCSession의 기본 구조
WatchConnectivity 프레임워크의 핵심 클래스는 WCSession입니다. 해당 클래스는 실시간 양방향 통신, 백그라운드 전송 등의 기능을 지원합니다. WCSession을 사용하기 위해서는 아래와 같은 기본 구조가 구현되어야 합니다.
import WatchConnectivity
class ExampleViewModel: NSObject, ObservableObject {
override init() {
super.init()
if WCSession.isSupported() {
let session = WCSession.default
session.delegate = self
session.activate()
}
}
...
}
extension ExampleViewModel: WCSessionDelegate {
// 세션 활성화 완료 시, 실행되는 함수
func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {
if let error = error {
print("WCSession activation failed: \(error.localizedDescription)")
}
}
}
위 구조에서 sendMessage 함수 및 수신을 담당하는 델리케이트 메서드를 아래와 같이 추가하면, 실시간 데이터 송신 및 수신을 구현할 수 있게 됩니다.
import WatchConnectivity
class ExampleViewModel: NSObject, ObservableObject {
override init() {
super.init()
if WCSession.isSupported() {
let session = WCSession.default
session.delegate = self
session.activate()
}
}
// sendMessage 함수 추가
private func sendMessageToOtherOS(_ message: [String: Any]) {
guard let session = wcSession, session.isReachable else {
self.message = "not reachable"
return
}
session.sendMessage(message, replyHandler: nil) { error in
print("Error sending message: \(error.localizedDescription)")
}
}
}
// WCSession 객체로부터 받은 메시지를 인식하여 콜백함수들을 실행할 대리자
extension ExampleViewModel: WCSessionDelegate {
func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {
if let error = error {
print("WCSession activation failed: \(error.localizedDescription)")
}
}
// 데이터 수신 델리게이트 메서드 추가
func session(_ session: WCSession, didReceiveMessage message: [String : Any]) {
if let command = message["example"] as? String {
// UI 업데이트 누락 혹은 지연을 방지하기 위해 메인스레드에서 실행
DispatchQueue.main.async {
// 수신 메시지 처리 구문
}
}
}
}
하지만 위처럼 각 뷰모델에서 WCSession.default를 접근하여 통신을 구현할 경우, 아래와 같은 문제가 발생한다는 것을 알게 되었습니다.
- 책임이 분산되어, 전체적인 관리가 어려워짐.
- 각 뷰모델마다 세션 상태 처리 및 오류 처리가 명시되기 때문에, 결과가 일관되지 않아 사용자 경험이 저하될 수 있음.
- 여러 뷰모델로부터 다수의 WCSession 델리게이트를 설정할 경우, 마지막 델리케이트만 작동하게 되면서, 충돌이 발생할 수 있음.
- 동일한 코드가 양쪽 OS에서 공존하므로, Boilerplate로 인한 용량이 증가되고, 성능이 저하됨.
- 도중이 연결이 끊길 경우, 이를 사용자가 알 수 없음
해결방안을 고민하던 중, WCSession 인스턴스의 접근 코드를 보고 힌트를 얻을 수 있었는데요.
if WCSession.isSupported() {
// default 속성을 통해 WCSession의 싱글톤 인스턴스를 접근
let session = WCSession.default
}
Apple의 공식문서에서는 앱 당 하나의 WCSession 인스턴스만 존재해야 한다고 명시가 되어있습니다. 이를 위해 WCSession은 싱글톤 패턴을 사용하고 있으며, .default 속성을 통해 전체 프로젝트에서 동일한 인스턴스에 접근할 수 있도록 하고 있습니다.
3. WCSession을 중심으로 구성한 WatchConnectivityManager
이와 유사하게 싱글톤 패턴을 사용해, 중앙화된 WatchConnectivityManager를 구현해보았습니다.
import WatchConnectivity
// 커스텀 에러 타입
enum WatchConnectivityError: Error {
case sessionNotActivated
case watchNotReachable
}
// 노티피케이션 이름 확장
extension Notification.Name {
static let watchUnreachable = Notification.Name("WatchUnreachableNotification")
static let watchReachabilityChanged = Notification.Name("WatchReachabilityChangedNotification")
}
class WatchConnectivityManager: NSObject {
static let shared = WatchConnectivityManager() // 싱글톤 패턴
private var session: WCSession?
private var timer: Timer?
var messageReceiver: (([String: Any]) -> Void)?
private override init() {
super.init()
setupSession()
}
private func setupSession() {
if WCSession.isSupported() {
session = WCSession.default
session?.delegate = self
session?.activate()
}
}
// 5초마다 연결 상태 확인 시작
func startMonitoringConnectionStatus() {
timer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { [weak self] _ in
self?.checkConnectionStatus()
}
}
// 주기적인 연결 상태 확인 중지
func stopMonitoringConnectionStatus() {
timer?.invalidate()
timer = nil
}
private func checkConnectionStatus() {
guard let session = session else { return }
if session.activationState == .activated {
if session.isReachable == true {
print("Watch is reachable")
} else {
DispatchQueue.main.async {
NotificationCenter.default.post(name: .watchUnreachable, object: nil)
}
}
} else {
self.session?.activate()
}
}
// 메시지 전송 메서드
func sendMessage(_ message: [String: Any], errorHandler: ((Error) -> Void)? = nil) {
guard let session = session, session.isReachable else {
DispatchQueue.main.async {
errorHandler?(WatchConnectivityError.watchNotReachable)
}
return
}
session.sendMessage(message, replyHandler: nil) { error in
DispatchQueue.main.async {
errorHandler?(error)
}
}
}
}
// WCSessionDelegate 메서드들
extension WatchConnectivityManager: WCSessionDelegate {
func session(_ session: WCSession, didReceiveMessage message: [String : Any]) {
DispatchQueue.main.async { [weak self] in
self?.messageReceiver?(message)
}
}
func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {
if let error = error {
print("Session activation failed with error: \(error.localizedDescription)")
return
}
print("Session activated with state: \(activationState.rawValue)")
}
// 연결 상태 변화를 감지하고 노티피케이션을 발송
func sessionReachabilityDidChange(_ session: WCSession) {
DispatchQueue.main.async {
NotificationCenter.default.post(name: .watchReachabilityChanged, object: nil)
}
}
#if os(iOS)
public func sessionDidBecomeInactive(_ session: WCSession) { }
public func sessionDidDeactivate(_ session: WCSession) {
self.session?.activate()
}
#endif
// 다른 필요한 WCSessionDelegate 메서드 구현
}
*물론, 이는 WatchOS와 iOS 모두 읽을 수 있는 클래스여야 합니다. 우측 Inspector 창의 첫번째 탭에 있는 Target Membership 창에서 + 버튼을 눌러 WatchOS와 iOS 타겟이 모두 있게끔 설정해줍니다.
위 전체코드를 기능별로 나누어서 설명하도록 하겠습니다.
3.1. 세션 연결상태 추적 로직
private var session: WCSession?
private var timer: Timer?
var messageReceiver: (([String: Any]) -> Void)?
private override init() {
super.init()
setupSession()
}
private func setupSession() {
if WCSession.isSupported() {
session = WCSession.default
session?.delegate = self
session?.activate()
}
}
// 5초마다 연결 상태 확인 시작
func startMonitoringConnectionStatus() {
timer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { [weak self] _ in
self?.checkConnectionStatus()
}
}
// 주기적인 연결 상태 확인 중지
func stopMonitoringConnectionStatus() {
timer?.invalidate()
timer = nil
}
private func checkConnectionStatus() {
guard let session = session else { return }
if session.activationState == .activated {
if session.isReachable == true {
print("Watch is reachable")
} else {
DispatchQueue.main.async {
NotificationCenter.default.post(name: .watchUnreachable, object: nil)
}
}
} else {
self.session?.activate()
}
}
WatchConnectivityManager 스코프 내에서 접근이 가능한 session 변수를 생성한 후에, 초기화 시 싱글톤으로 접근해 활성화시킨 WCSession을 해당 변수에 할당합니다. 또한 도중에 세션연결이 끊길 수 있기 때문에 주기적으로 연결상태를 확인하면서, 연결이 끊겼을 경우, 재활성화를 시도하거나 뷰모델에서 수신할 수 있는 Notification을 post해줍니다.
3.2 메시지 전송 및 수신 로직
class WatchConnectivityManager: NSObject {
...
// WCSession이 제공하는 sendMessage 메서드 호출
func sendMessage(_ message: [String: Any], errorHandler: ((Error) -> Void)? = nil) {
// 활성화된 WCSession 있고, 상대 기기 연결이 가능한지 여부 체크
guard let session = session, session.isReachable else {
DispatchQueue.main.async {
errorHandler?(WatchConnectivityError.watchNotReachable)
}
return
}
// 비동기적으로 메시지 전송
session.sendMessage(message, replyHandler: nil) { error in
DispatchQueue.main.async {
errorHandler?(error) // 에러 발생시 호출
}
}
}
}
// WCSessionDelegate 메서드들
extension WatchConnectivityManager: WCSessionDelegate {
// 상대 기기로부터 메시지 수신 시 호출되는 구문
func session(_ session: WCSession, didReceiveMessage message: [String : Any]) {
// UI 업데이트의 안정성을 보장하기 위해 메인스레드에서 실행
DispatchQueue.main.async { [weak self] in
self?.messageReceiver?(message)
}
}
저의 경우, WatchOS와 iOS간 쌍뱡항 통신이 아닌, 단방향 통신을 구현하는 것이 목표였기 때문에, sendMessage 메서드 실행 시, replyHandler를 명시하지 않았습니다. 만일 상대기기로 메시지를 송신할때, 즉시 상대기기로부터 응답 메시지를 받고자 할 때에는 replyHandler를 통해서 구현이 가능합니다!
WCSessionDelegate를 단일 클래스로 통합하는 과정에서 가장 큰 문제는 각 뷰모델별로 메시지 수신 처리를 분기하는 방안을 구상하는 것이었습니다. 메시지 전송 로직은 뷰모델 간 차이가 없었지만, 메시지 수신 처리는 각 뷰모델마다 대응해야 할 메시지 키값과 처리 로직이 상이했기 때문입니다. 초기에는 Combine 프레임워크의 PassthroughSubject를 활용하여 모든 뷰모델에 메시지를 전파하는 방식을 시도해보았습니다. 하지만 Combine 프레임워크에 대한 이해가 부족했던 탓인지, 메시지가 의도한 대로 수신되지 않았습니다.
그 이후에 시도해본 방안이 클로저를 통해 메시지 처리 로직을 외부에서 주입받는 방안이였습니다.
// var messageReceiver: (([String: Any]) -> Void)?
// 단일 messageReceiver로 외부로부터 메서드 주입받을 경우, 마지막으로 설정된 messageReceiver만 동작
var iOSMessageReceiver: (([String: Any]) -> Void)?
var watchMessageReceiver: (([String: Any]) -> Void)?
수신된 메시지를 인자로 받고, Void를 반환받는 클로저를 WatchConnectivityManager 내부에 선언해줍니다. 이는 외부에서 함수를 주입할때 사용하는 변수가 되므로, private은 제거해줍니다.
실제 처리로직이 있는 뷰모델과 메시지를 수신받는 WatchConnectivityManager 간의 징검다리 역할을 한다고 생각하면 쉽습니다.
이때 messageReceiver 클로저 변수는 타겟별로 나누어 생성해줘야 합니다. 왜냐하면, receiver를 주입하는 타겟은 2개 이상이지만, WatchConnectivityManager는 단일 인스턴스로 존재하게 되면서, Receiver가 서로 덮어씌워질 수 있기 때문입니다.
func session(_ session: WCSession, didReceiveMessage message: [String : Any]) {
DispatchQueue.main.async { [weak self] in
#if os(iOS)
self?.iOSMessageReceiver?(message)
#else
self?.watchMessageReceiver?(message)
#endif
}
}
그 후, Delegate를 통해 메시지를 수신하게 되면, 해당 메시지를 현재 타겟에 대한 messageReceiver 함수로 전달 및 실행합니다.
class HeartRateViewModel: ObservableObject {
let manager = WatchConnectivityManager.shared
init() {
setupMessageReceiver()
}
private func setupMessageReceiver() {
manager.iOSMessageReceiver = { [weak self] message in
if let heartRate = message["heartRate"] as? Double {
DispatchQueue.main.async {
self?.currentHeartRate = heartRate
}
}
}
}
}
그 이후에, 메시지 수신처리가 있는 뷰모델에서 초기화 시, iOSMessageReceiver 클로저 변수에 메시지 수신에 대한 처리로직을 주입시켜주면 됩니다.
// watchOS 타겟 내부 뷰모델
class HeartRateMonitorViewModel: NSObject, ObservableObject {
let manager = WatchConnectivityManager.shared
override init() {
super.init()
setupMessageReceiver()
}
private func setupMessageReceiver() {
manager.iOSMessageReceiver = { [weak self] message in
if let heartRate = message["heartRate"] as? Double {
DispatchQueue.main.async {
self?.currentHeartRate = heartRate
}
}
}
}
}
// iOS 타겟 내부 뷰모델
class HeartRateViewModel: NSObject, ObservableObject {
let manager = WatchConnectivityManager.shared
override init() {
super.init()
setupMessageReceiver()
}
private func setupMessageReceiver() {
manager.watchMessageReceiver = { [weak self] message in
guard let self = self else { return }
if let command = message["command"] as? String {
DispatchQueue.main.async {
switch command {
case "startMonitoring":
if !self.isMonitoring {
self.startMonitoring()
}
case "stopMonitoring":
if self.isMonitoring {
self.stopMonitoring()
}
default:
print("Unknown command received: \(command)")
}
}
}
}
}
}
설령 저의 경우, watchOS에서 대응해야하는 메시지 조건 및 로직이 iOS와 달랐기 때문에, 이부분을 중점으로 함수를 달리 구성하여 초기화 시 설정해주었습니다.
4. 뷰모델에서의 WatchConnectivityManager 연결
위 WatchConnectivityManager 클래스의 타겟멤버가 iOS와 watchOS 모두 추가되었다면, 양쪽 타겟에서 아래와 같은 형태 코드를 통해 WCSession 접근 및 사용이 가능해집니다.
class HeartRateViewModel: NSObject, ObservableObject {
let manager = WatchConnectivityManager.shared
override init() {
super.init()
manager.startMonitoringConnectionStatus() // 세션상태확인 시작
setupNotificationObservers()
setupMessageReceiver()
}
private func setupNotificationObservers() {
NotificationCenter.default.addObserver(forName: .watchUnreachable, object: nil, queue: .main) { _ in
self.message = "Watch became unreachable"
}
NotificationCenter.default.addObserver(forName: .watchReachabilityChanged, object: nil, queue: .main) { _ in
self.message = "Watch reachability changed"
}
}
// 메시지 수신
private func setupMessageReceiver() {
manager.messageReceiver = { [weak self] message in
if let heartRate = message["heartRate"] as? Double {
DispatchQueue.main.async {
self?.currentHeartRate = heartRate
}
}
}
}
// 메시지 송신
private func sendMessageToWatch(_ message: [String: Any]) {
manager.sendMessage(message) { error in
DispatchQueue.main.async {
self.message = "Error sending message: \(error.localizedDescription)"
}
}
}
}
위에 소개한 sendMessage(실시간 전송) 방법 외에도 transferUserInfo와 updateApplicationContext라는 메서드도 제공하고 있으나, 이는 나중에 자세히 톺아보도록 하겠습니다.
감사합니다.
'개발지식 정리 > Swift' 카테고리의 다른 글
iOS Core Data 프레임워크로 데이터 구조화 및 유지하기 (1) | 2024.10.24 |
---|---|
Swift에서의 동시성 프로그래밍(Concurrent Programming) (2) | 2024.10.21 |
PhaseAnimator, Keyframes으로 고급 애니메이션 구현하기 (6) | 2024.10.16 |
SwiftUI 애니메이션 정리(Animatable, Animation, Transaction) (0) | 2024.10.15 |
애플워치로 측정한 심박수 iOS 앱으로 전송하기 (6) | 2024.10.10 |