맛동산이

TCA에서 watchConnectivity활용하기(feat. dependency) 본문

앱/SwiftUI

TCA에서 watchConnectivity활용하기(feat. dependency)

진ddang 2025. 2. 11. 13:01

swiftUI를 통해서 Watch앱을 구성해야하는데, 이때 TCA를 도입하였습니다.

하지만, watch는 watchConnectivity를 사용해야하는데, 이때 watchConnectivity는 Delegate로 다 구현되어있습니다.

하지만 TCA에서는 이러한 외부 의존성에 대해서는 Dependency를 사용하고 있기 때문에 기존의 delegate를 래핑할 필요가 있었습니다.

따라서 해당 코드를 짜기 이전에 swift concurrency를 간단하게 이해하고, 래핑하는 코드까지 살펴보도록 하였습니다.

Swift concurrency

내부적으로 어떻게 많이 돌아가고 하는 내용은 조금 제외하고, 간략하게 설명하면서 swift concurrency와 asnycStream과 같은 내용을 정리해봅시다.

async func

func somefunction() async -> () { ... }

위와같이 함수명 뒤에 async를 통해서 해당 함수가 비동기(asynchronous)라는것을 알려줍니다.

해당 키워드를 사용한 함수는 await키워드와 함께 사용해야합니다.

또한 해당 키워드의 함수는 Task()안에서 또는, asnyc 함수 내부에서만 사용가능합니다.

 

await

Task { 
	await asyncfunction()
}

await은 asnyc함수가 사용될때 반듯이 사용해야하는 키워드입니다.

해당 키워드를 사용한 지점은 suspension point가 됩니다.

suspension point is a point in the execution of an asynchronous function where it has to give up its thread

suspension Point는 위와같이 스레드 제어권을 포기하는 지점입니다.

이때 스레드 제어권이라는 포인트가 굉장히 중요한데요.

이를 도식화 하면 다음과 같습니다.

await 키워드를 만나면, 해당 thread의 제어권을 가져와 다른 Task를 실행하게 되고, 해당 비동기 함수가 종료되면 thread의 제어권을 돌려주는 것으로 작업이 이어집니다.

이때 다시 되돌려주는 thread는 이전의 thread와 동일하다고는 장담할수 없습니다.

 

이전 GCD에서는 작업을 하게 되었을때, thread를 블락하게 되는데, concurrency에서는 thread를 suspension합니다.

이때 변수와 같은 값들을 기존에는 stack에 저장하지만, heap에 저장하고, 해당 포인트를 기억하는게 suspension 포인트입니다.

즉 context switching할때 필요한 포인트를 suspesnion point라고 합니다.

이때 런타임에 이러한 추적을하는게 continuation입니다.

따라서 continuation은 reuseme()과 yield(), finish()와 같은 함수로 스레드를 제어하는 역할을 하는 객체입니다.

 

Task

task는 asynchronous 함수를 실행할수 있는 unit입니다. Task는 취소가능하고, 대기가능합니다.

AsyncStream

AsyncStream 프로토콜에 준수성을 추가하여 for-await-in 루프에서 자체 타입을 사용할 수있습니다.

이게 무슨말이냐 하면, AsyncStream은 기존의 Sequence와 동일하게 이터레이터가 있으며, 해당 스트림을 순환할수 있습니다. ( for - in 구문을 통해 )

또한 내부에서 await을 통해서 비동기적으로 순차적으로 Sequence에 있는 비동기 함수와 메소드등과 같이, 자체적으로 suspension point를 가질수 있습니다.

이를 통해서 다음요소를 사용할수 있을때 까지 기다리고, 시작되고 를 반복할 수 있습니다.

asyncStream을 만들때 makeStream(of: ) 를 통해서 만드는것처럼 내부적으로 continuation과 stream이 있습니다.

따라서 해당 stream의 흐름을 관리하는것 또한 continuation이다.

 

withCheckedContinuation()

해당 함수를 통해서 completionHandler로 값을 리턴하는것을 async await함수로 사용할수 있도록 합니다.

func requestImageAsycAwait() async -> Data {
    return await withCheckedContinuation { continuation in
        someFunction { data in
            continuation.resume(returning: data)
        }
    }
}

위와 같은 방법으로 withCheckedContinuation을 사용할수 있는데 이때 간단한 테스트를 통해서 Thread를 체크해보면 다음과 같습니다.

 

아무튼 withCheckedContinuation 앞의 await키워드의 suspension point가 발생하고 해당 continuation의 return을 continuation.resume()을 통해서 리턴해줍니다.

 

WatchConnectivity 래핑

Dependency

TCA에서 Reducer는 내부의 State와 Action을 알고 있습니다.

뷰는 Store를 가지고 있고, 뷰에서 Store를 통해 Action을 보냅니다.

Action을 통해서 Reducer내부에서 State를 바꾸는 그러한 과정을 거칩니다.

도식화를 하면 다음과 같습니다.

이때 외부에서 상태를 업데이트 받아야하는 그러한 트리거가 필요할때 바로 Dependency를 사용하게 됩니다.

  1. @DependencyClient 가 있는 Model만들기
  2. Extension을 통해서 변수 선언하기
  3. Model에 DependencyKey Protocol 선언하기
  4. TCA Dependencies System에 쓰일 Static변수 설정하기

의 순서로 작성하면 됩니다.

해당 내용은 채널톡의 globalState관리와 비슷한 면이 있어서 참고하면 좋습니다.

https://channel.io/ko/blog/articles/416ea23e

 

Swift Composable Architecture 를 도입하며 겪었던 문제와 해결법

채널톡 고객사의 성공 사례와 비즈니스 인사이트를 확인하세요

channel.io

 

Store에 연결하기(데이터 수신)

Dependency를 작성했기 때문에 Store에 주입해줘야합니다.

@Dependency(\\.watchConnectivityClient) var watchClient

 

그리고 나서 저희는 AsyncStream을 통해서 해당 뷰가 없어질때까지, Connenctivity로 들어오는 이벤트를 비동기적으로 받아야합니다. 이를 위해서 onAppear에서 for await in 문을 통해서 저희가 만들었던, watchConnectivity의 asyncStream을 보도록 합니다.

 

case .appearTask:
    return .run { send in
        await watchClient.activate()
        await withTaskGroup(of: Void.self) { group in
            group.addTask {
                await withTaskCancellation(id: CancelID.watchConnectivity, cancelInFlight: true) {
                    for await action in await watchClient.delegate() {
                        await send(.watchConnectivity(action), animation: .default)
                    }
                }
            }
        }
    }
    
@ReducerBuilder<State, Action>
var watchClientReducer: some ReducerOf<Self> {
    Reduce { state, action in
        switch action {
            case .watchConnectivity(.didReceiveMessage(let message)):
                // WatchClientを使用して、dataを受け取る
            if let data = message?["date"] as? Data {
                print(data)
            }
            if let data = message?["date"] as? Data,
               let receivedDate = try? JSONDecoder().decode(Date.self, from: data) {
                print(data)
                state.receivedData = receivedDate.description
            } else {
                state.receivedData = "fail to parse"
            }
            return .none
            
            default:
                return .none
        }
    }
}

여기까지 데이터를 받는 쪽입니다.

 

Action에 연결하기(데이터를 송신)

case .sendCurrentDate:
    // WatchClientを使用して、dataを送る。
    state.message = "send Data'"
    return .run { _ in
        if let data = try? JSONEncoder().encode(Date.now) {
            await watchClient.sendMessage(("date", data))
        }
    }

위와같은 방법으로 delegate를 래핑해서 사용할수 있습니다

 

시연영상

 

delegate를 래핑한 dependency는 잘 동작하는걸 확인할 수 있습니다 .

  • 추가로 watchConnectivity는 디버그 브레이크포인트가 동작하지 않음
반응형