~18 min · on-the-wrist, watchconnectivity, wcsession, delegates, property-lists
Level 0번들 열어본 사람
0 XP0/81 lessons0/17 achievements
0/100 XP to next level100 XP to go0% complete
"손목에서 한 메시지가 폰 앱을 열기 전까지 512초 동안 가만히 있었어."
보내는 법 둘, 약속도 둘
WCSession에는 워치와 폰 사이에 데이터를 옮기는 방법이 여럿 있는데, 담기에 중요한 건 그중 둘이야. transferUserInfo는 기록이야. 시스템이 줄을 세워 순서대로 보내고, 두 앱이 다 꺼져도 사라지지 않아. 도착하면 보낸 쪽에 didFinish도 알려줘. sendMessage는 실시간이라서 상대에게 닿을 수 있을 때만 돼. 가족 워치 앱은 처음에 백그라운드 전송이 아이폰 앱을 깨워서 배달해준다고 믿고 만들었어. 근데 SDK 헤더 설명은 달라. 전송은 상대한테 "다음에 켜질 때 델리게이트 콜백"을 주고, sendMessage는 iOS 앱이 안 돌고 있으면 켜준다고. 주인이 그 차이를 재봤어. 폰을 잠근 채 주머니에 넣어두니, 손목에서 보낸 메시지가 512초 동안 꼼짝도 안 하다가 직접 폰 앱을 열자 그제야 넘어갔어.
그래서 워치는 같은 봉인된 봉투를 두 방법으로 다 보내. 전송은 기록으로 남고, 워치는 전송의 didFinish에서만 줄의 항목을 놓아줘. 메시지는 지금 폰 앱을 깨우는 초인종이야. 폰은 id를 기억해 두는 문 하나로 둘 다 받아서 반영하고, 늦게 도착한 쪽은 메아리로 보고 버려. 폰은 문이 담기를 저장한 뒤에야 초인종에 대답하고, 전송이 아직 마무리되지 않았어도 워치는 그 대답을 보고 폰이 받았다고 말할 수 있어.
비어 있는 옵셔널은 속성 목록 값이 아니야
훈련 앱에서 모듈 탭으로 담은 워치 기록은 폰으로 완벽하게 건너갔는데, 말로 담은 기록은 한 번도 안 왔어. 그런데도 워치는 기록마다 저장됨, 대기 중, 동기화됨이라고 멀쩡히 보여줬어. 페이로드가 선택 필드 두 개를 moduleRef as Any로 썼는데, 그러면 [String: Any]에 Optional.none이 들어가. transferUserInfo는 속성 목록 값만 받아. 딕셔너리 자체는 받아주긴 했어. 전송은 끝내 도착하지 않았는데 손목의 신호는 전부 초록불 그대로였고. 전송 테스트는 딕셔너리를 만들어서 바로 다시 읽기만 했어. Swift는 그 정도는 문제없이 해주니까, 직렬화는 한 번도 거치지 않은 거지. 해결은 비어 있는 필드를 아예 빼는 거였고, 이제 공유 봉투는 봉인할 때 속성 목록이 아닌 값을 거절해.
델리게이트는 하나만, 강하게 붙잡고, 활성화는 뷰 없이
세션엔 델리게이트가 딱 하나고, 그 속성은 약한 참조야. 이 두 사실 때문에 실패가 세 번 났어. 네이티브 Pippa에선 설치 함수 안에서 전달용 중계 객체를 만들고 어디에도 저장하지 않았어. 그 객체가 곧바로 사라지면서 세션은 아무 경고 없이 델리게이트 없는 상태가 됐지. 활성화도 전송도 메시지도 없었는데, 워치는 폰이 생각 중이라고 표시했어. 훈련 앱에선 키트가 자기 프록시를 설치한 다음에 워치가 자기 카탈로그 저장소를 델리게이트로 또 넣었어. 나중에 넣은 쪽이 이기니까 키트는 didFinish를 못 받게 됐지. 그래서 워치는 아무것도 못 놓아주고 활성화될 때마다 이력 전체를 다시 내밀었어. 또 어떤 폰 앱은 루트 뷰의 .task에서 세션을 활성화했어. 초인종 때문에 백그라운드에서 켜질 땐 뷰가 없어서, 정작 중요한 그 실행에서 세션이 활성화되지 않았지. 정리하면 이래. 델리게이트는 붙잡아둔 하나만 유지해. 애플리케이션 컨텍스트는 델리게이트를 뺏어서 듣지 말고 receivedApplicationContext로 읽어. 활성화는 앱 초기화에서 하고, 폰에선 sessionDidDeactivate가 오면 다시 활성화해. 이번엔 상대가 다른 워치일 수도 있거든.
Code
속성 목록 값만 봉인하고, 나머지는 보내는 쪽에서 곧바로 거절하기·swift
import Foundation
enum SealError: Error, Equatable {
case notPropertyList(key: String)
}
/// What rides between watch and phone. Plain property-list values only: an absent optional
/// written as `Optional<String>.none as Any` is accepted by transferUserInfo and then never delivered.
enum WristEnvelope {
static let idKey = "id"
static func seal(id: String, fields: [String: Any]) throws(SealError) -> [String: Any] {
var info = fields
info[idKey] = id
for (key, value) in info {
guard PropertyListSerialization.propertyList([key: value], isValidFor: .binary) else {
throw .notPropertyList(key: key)
}
}
return info
}
static func identifier(in info: [String: Any]) -> String? { info[idKey] as? String }
}
// let place: String? = nil
// seal(id: "c1", fields: ["text": "hi", "place": place as Any]) -> throws notPropertyList(key: "place")
// if let place { fields["place"] = place } -> sealed, "place" simply absent
붙잡아둔 델리게이트 하나로 기록과 초인종을 보내고, 놓아주는 건 didFinish에서만·swift
import Foundation
import WatchConnectivity
/// The ONE delegate the session has. WCSession.delegate is weak and single: whoever assigns it
/// last owns every callback, and an unretained relay leaves the session with no delegate at all.
final class WristSessionProxy: NSObject, WCSessionDelegate, @unchecked Sendable {
static let shared = WristSessionProxy() // retained for the life of the process
var onFinished: @Sendable (String) -> Void = { _ in }
var onHandedOver: @Sendable (String) -> Void = { _ in }
/// Phone side: the one door. True once the capture is stored, now or by an earlier arrival.
var onReceive: @Sendable ([String: Any]) -> Bool = { _ in false }
func activate() {
guard WCSession.isSupported() else { return }
WCSession.default.delegate = self
WCSession.default.activate() // from App.init: a background launch has no view
}
/// The record: queued, ordered, survives both apps exiting, delivered on the phone app's next launch.
/// The ring: sendMessage launches the iOS app if it is not running. Same envelope, one door.
func offer(_ info: [String: Any]) {
let session = WCSession.default
session.transferUserInfo(info)
guard session.isReachable, let id = WristEnvelope.identifier(in: info) else { return }
let handedOver = onHandedOver
session.sendMessage(info, replyHandler: { reply in
// "The phone has it" is the phone's own word. The queue entry still waits for didFinish.
if reply["stored"] as? String == id { handedOver(id) }
}, errorHandler: nil)
}
func session(_ session: WCSession, activationDidCompleteWith state: WCSessionActivationState, error: Error?) {}
// The watch lets go of an entry ONLY here, never at send time and never on the ring's reply.
func session(_ session: WCSession, didFinish transfer: WCSessionUserInfoTransfer, error: Error?) {
guard error == nil, let id = WristEnvelope.identifier(in: transfer.userInfo) else { return }
onFinished(id)
}
func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any]) { _ = onReceive(userInfo) }
// The ring carries a reply handler, so it arrives here. Answer only after the door has stored it.
func session(_ session: WCSession, didReceiveMessage message: [String: Any],
replyHandler: @escaping ([String: Any]) -> Void) {
if onReceive(message), let id = WristEnvelope.identifier(in: message) {
replyHandler(["stored": id])
} else {
replyHandler([:])
}
}
#if os(iOS)
func sessionDidBecomeInactive(_ session: WCSession) {}
func sessionDidDeactivate(_ session: WCSession) { session.activate() } // it may be a different watch now
#endif
}
Spark 링크의 워치 쪽을 만들어 봐. WristEnvelope.seal로 담기를 봉인해서 전송과 메시지로 보내고, 워치 줄에선 didFinish가 왔을 때만 빼. 비어 있는 옵셔널을 Optional<String>.none as Any로 넣은 페이로드를 봉인하면 그 키 이름과 함께 거절되는지 확인하는 테스트도 추가해. 그러고 나서 폰 타깃용 소스 읽기 테스트를 넣어. 프록시가 아닌 다른 파일이 WCSessionDelegate를 따르거나 세션에 .delegate =를 넣으면 실패해야 해. 일부러 그런 줄을 하나 넣어서 정말 실패하는지 증명해.
Hint
PropertyListSerialization.propertyList(_:isValidFor: .binary)가 전송이 사실상 하는 검사야. 소스 테스트는 WatchConnectivity를 import하는 .swift 파일만 훑어. // 주석은 먼저 걸러내고, 실패 메시지엔 문제가 된 파일 이름을 적어.
Progress
Progress is local-only — sign in to sync across devices.