본문 바로가기
C.W.K.
Stream
Lesson 03 of 05 · published

존재 신호는 자기 통로로

~13 min · watchconnectivity, watchos, heartbeat, swift

Level 0길 잃음
0 XP0/36 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete

회전마다 폰이 알아야 하는 1비트

걷다가 회전이 오면 폰은 잠금 화면 알림을 올리기 전에 하나를 물어야 해. 워치 앱이 이 회전을 이미 직접 톡 치고 있나? 워치 내비게이션 세션이 돌고 있으면 그래. 거기다 알림까지 미러링되면 모퉁이 하나에 아빠 손목을 두 번 치는 거야. 그 규칙(TurnAlertPolicy.shouldAlert)과 워치 쪽 짝은 5트랙에서 봤어. 이번 레슨은 그 규칙이 기대는 입력 얘기야. 워치 세션이 살아 있다는 걸 폰이 어떻게 아는지, 그리고 첫 답이 왜 틀렸는지.

그걸 아는 건 워치뿐이니까 워치가 말해야 해. 세션이 도는 동안엔 watchingEvery = 30초마다 말하고, 세션이 끝나면 그 자리에서 한 번 말해. 폰은 "살아 있어"를 watchingTrust = 75초 동안 믿어. 박동 두 번을 놓치고도 여유가 조금 남는 길이야. 앱을 쓸어 올려 꺼버린 워치는 "끝났어"를 말할 기회도 없어. 그러면 폰은 그냥 믿기를 멈추고, 알림이 다시 나가.

첫 버전: 영구 큐 안의 심장 박동

첫 손목은 이 박동을 아빠의 탭과 같은 길로 보냈어. link.capture(.watching(true)). 레슨 1에서 본 통로야. 워치는 항목마다 자기 outbox에 쓰고, transferUserInfo에 넘기고, 할 수 있으면 폰에 벨을 울리고, transfer가 도착했다고 보고할 때만 놓아. 탭엔 딱 맞아. 심장 박동엔 천천히 새는 구멍이고.

작업대 위, 폰이 곁에 없는 워치 시뮬레이터에서 컨트롤 페이지가 3분 뒤 "폰을 기다리는 항목 4개", 6분 뒤엔 10개를 보여줬어(학습 기록 #734). 폰이 없으면 도착할 수가 없으니 놓을 수도 없고, 박동마다 하나씩 늘어. 실제로 폰이 닿지 않는 산책이면 1분에 두 개야. 그러다 폰이 돌아오면 flush가 쌓인 걸 통째로 다시 내밀고, 낡은 "나 살아 있어" 하나하나마다 transfer 한 번, 벨 한 번이 들어. 폰 앱은 몇 번이고 깨어나서 몇 분 전에 이미 거짓이 된 말들을 차례로 들어.

통로가 고장 난 건 아니야. 행위용 통로에 사실이 타고 있었던 거지.

고친 방법: 워치 자신의 application context

application context엔 끝이 둘이야. 폰이 updateApplicationContext를 부르면 워치의 receivedApplicationContext가 채워지고, 워치가 부르면 폰 쪽이 채워져. Pathfinder는 글랜스에 아래쪽 절반만 쓰고 있었어. 커밋 fc2558d는 위쪽 절반을 존재 신호에 썼어. 워치가 WristCommand(action: .watching(live))를 봉해서 자기 application context로 발행해. 최신이 이기니까, 폰이 없는 동안엔 30초마다 덮어쓰는 칸 하나만 있어. 큐가 아니야. 벨도 없으니 주머니 속에서 자는 폰을 이걸로 깨울 일도 없고. 바꾼 뒤엔 세션이 2분 내내 도는 동안 작업대 위 outbox가 원래 개수에서 꼼짝 안 했어.

폰도 이걸 귀 기울여 기다리지 않아. 회전 순간에 WatchBridge.watchLive가 receivedApplicationContext를 읽고, WristCommand로 열어서, TurnAlertPolicy.watchLive(presence:now:)에 넘겨. 레슨 1의 키트 규칙("듣지 말고 읽어")이 반대 방향으로 도는 거야. updateApplicationContext는 델리게이트 콜백이 아니라 메서드라서, 두 기기 모두 키트의 프록시가 세션의 유일한 델리게이트로 남아.

사실을 읽는 법: 자기 시계, 자기 종류

읽은 걸 믿을 수 있게 하는 장치가 둘 있어.

  • 사실이 자기 나이를 들고 다녀. 살아 있는지는 폰이 우연히 읽은 시각이 아니라 존재 신호 자신의 issuedAt으로 판정해. 폰이 10분 뒤에 읽어도 그 컨텍스트는 자기가 몇 살인지 정확히 말해. 최신 한 마디만 남기는 통로에선 그 한 마디가 워치가 한 유일한 말이니까, 거기 찍힌 시각이 진실 전부야.
  • 존재 신호만 쳐줘. 폰은 컨텍스트 칸을 WristEnvelope.open(WristCommand.self, from:)로 여는데, 이게 봉투 종류부터 확인해. 다른 게 들어 있으면 아무 말도 없었던 걸로 읽어. 앱 테스트는 글랜스를 봉해서 watchLive에 넘기고 false인지 확인해("only a presence counts").

워치 코드에서 빠진 것도 두 줄 읽어볼 만해. announcePresence는 세션이 활성화되기 전엔 아무것도 안 하고, 다시 시도하지도 않아. 그렇게 잃어버린 말은 다음 박동이 다시 해. 그리고 폰의 명령 문은 캡처 통로로 들어온 .watching을 여전히 거절해. 옛날 워치 빌드든 실수든, 존재 신호를 다시 명령으로 되돌릴 수는 없어.

사실은 타임스탬프를 달고 최신값이 이기는 통로로 보내고, 나이는 읽는 쪽이 판정해. 행위는 영구 통로로, id당 한 번, 받는 쪽 만료를 붙여서 보내. 메시지 하나가 큐와 심장 박동을 둘 다 원한다면, 그건 아마 메시지 두 개야.

Code

Watch/WatchModel.swift: announcePresence, 원문 그대로·swift
  /// The presence goes up as the watch's own application context, never
  /// through the capture lane: latest wins, nothing queues while the phone is
  /// away, and nothing rings it awake. The phone reads it when a turn comes
  /// (`WristCommand.Action.watching` has the story). A word lost before the
  /// session activates is said again on the next beat, and a phone that heard
  /// none stops believing the last one after `watchingTrust`.
  private func announcePresence(_ live: Bool) {
    guard WCSession.isSupported(), WCSession.default.activationState == .activated,
      let sealed = try? WristEnvelope.seal(WristCommand(action: .watching(live)))
    else { return }
    try? WCSession.default.updateApplicationContext(sealed)
  }
PathfinderKit, Wrist/WristCommand.swift: TurnAlertPolicy.watchLive, 원문 그대로·swift
  /// Whether the watch's session counts as live, from its last word.
  public static func watchLive(lastWatching: Date?, live: Bool, now: Date = Date()) -> Bool {
    guard live, let lastWatching else { return false }
    return now.timeIntervalSince(lastWatching) <= WristCommand.watchingTrust
  }

  /// The same, from the presence the watch published as its application
  /// context. That lane keeps only the latest, so its last word is its only
  /// word; anything but a presence counts as no word at all.
  public static func watchLive(presence: WristCommand?, now: Date = Date()) -> Bool {
    guard let presence, case .watching(let live) = presence.action else { return false }
    return watchLive(lastWatching: presence.issuedAt, live: live, now: now)
  }
최소 스케치: 박동 하나, 통로 둘, 폰 없이 6분 (swiftc로 컴파일해서 돌려봤어)·swift
import Foundation

// A minimal sketch: the same 30 s heartbeat on two lanes while the phone is
// away for six minutes, then what the phone believes when a turn asks.
// Times are seconds, passed in, so the run is deterministic.
struct Presence {
  let live: Bool
  let issuedAt: TimeInterval
}

let watchingEvery: TimeInterval = 30
let watchingTrust: TimeInterval = 75

// The capture lane: a durable queue that lets go only when a transfer lands.
var queue: [Presence] = []
// The application context: one slot, the latest replaces the last.
var context: Presence?

let away: TimeInterval = 360
for beat in stride(from: 0.0, to: away, by: watchingEvery) {
  let word = Presence(live: true, issuedAt: beat)
  queue.append(word)  // nothing lands while the phone is away
  context = word  // the slot just holds the newest
}
print("after \(Int(away / 60)) min away: queue holds \(queue.count), context holds \(context == nil ? 0 : 1)")
print("on reconnect the queue re-offers \(queue.count) transfers and \(queue.count) rings")

// The phone's question at a turn: is the watch's session live?
func watchLive(_ presence: Presence?, now: TimeInterval) -> Bool {
  guard let presence, presence.live else { return false }
  return now - presence.issuedAt <= watchingTrust
}
let last = context!.issuedAt
for gap in [20.0, 70.0, 120.0] {
  print("turn \(Int(gap)) s after the last beat: live = \(watchLive(context, now: last + gap))")
}
print("session ended (live: false), 1 s later: live = \(watchLive(Presence(live: false, issuedAt: last), now: last + 1))")

// Output:
// after 6 min away: queue holds 12, context holds 1
// on reconnect the queue re-offers 12 transfers and 12 rings
// turn 20 s after the last beat: live = true
// turn 70 s after the last beat: live = true
// turn 120 s after the last beat: live = false
// session ended (live: false), 1 s later: live = false

External links

Exercise

네 시스템에서 심장 박동이나 상태 핑, "아직 여기 있어" 신호를 하나 찾아봐. 살아 있다고 보고하는 워커든, 접속 상태를 보내는 클라이언트든, 배터리를 알리는 기기든. 그게 어떤 통로를 타는지 적어. 큐나 재시도 루프처럼 메시지를 하나도 안 버리는 통로라면, 실제로 겪어본 가장 긴 장애 동안 사본이 몇 개 쌓이는지 계산해. 장애가 끝나면 받는 쪽이 그걸 어떻게 처리하는지도 적어. 그다음 최신값이 이기는 버전을 그려봐. 칸 하나, 값 안의 타임스탬프, 읽는 쪽의 신뢰 창.
Hint
신뢰 창은 느낌 말고 박동 간격에서 정해. 박동 두 번보다 조금 길면 메시지 하나를 잃어도 상태가 깜빡이지 않아. 칸에 존재 신호가 아닌 게 들어 있을 때, 그리고 한 번도 채워진 적 없을 때 읽는 쪽이 뭘 하는지도 확인해.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고

댓글 0

🔔 답글 알림 (로그인 필요)
로그인 — 댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.