RunWay 1.4.1 실기기에서만 보이던 것들
RunWay 1.1 부터는 Claude Code 와 함께 만든다. 코드와 글의 문장은 거기서 나오고, 방향을 정하고 실기기에서 확인해 틀린 걸 찾아오는 건 직접 한다. 그래도 과정을 글로 남기는 건 업데이트를 AI 에게 맡기고 끝내지 않으려는 것이다. 거기서 배울 점을 찾아 적어 두고 나중에 공부하려고 한다.
1.4가 10월 6일에 나갔다. 그 뒤로 지인과 내가 쓰면서 걸린 것들을 모았다. 전부 새 기능이 아니라 이미 있는 것이 잘못 보이거나 아예 안 나오던 것이라 세 번째 자리를 붙여 1.4.1로 낸다.
하나도 시뮬레이터에서는 안 보였다. 20km를 넘겨 뛰어야, 네 줄을 나란히 놓고 봐야, 워치에 이어폰을 꽂고 나가야 드러나는 것들이다.
20km를 넘기니 잘린 거리
DIST가 20.0...이다. 그 아래 경과 시간 03:37:45는 멀쩡하다.
같은 칸인데 한 줄만 다르게 생겼다.
1
2
3
4
5
6
7
8
Text(String(format: "%.2f", distance))
.font(.orbitron(12, weight: .bold))
.foregroundColor(.rwText) // 여기엔 없다
Text(elapsed)
.font(.orbitron(10, weight: .bold))
.foregroundColor(.rwMuted)
.minimumScaleFactor(0.7) // 여기엔 있다
.lineLimit(1)
글자가 안 들어가면 줄이라는 설정이 경과 시간에만 붙어 있었다.
왜 20km에서야 났는지가 이 버그의 성격을 말해준다. 19.87은 들어가는데 20.03이 안 들어간다. 글자 수가 같은데도 그렇다. Orbitron은 숫자마다 폭이 달라서 2와 0이 1보다 넓다. 1.4 작업 중에 워치 스테퍼가 줄바꿈되던 것도 같은 뿌리였는데, 그때는 그 칸만 고치고 끝냈다.
고치면서 옆 칸도 같이 막았다. HR, CAD, ALT 가 공유하는 칸인데, 고도는 음수가 될 수 있어서 -120이면 네 글자다. 아직 안 터졌을 뿐 같은 자리다.
빠를수록 아래로 가던 페이스
FLIGHT DATA RECORDER의 네 줄 중 PACE만 빠른 구간이 바닥에 깔려 있었다. 빠를수록 위에 있어야 하지 않냐는 지적을 받았다.
원인은 네 줄이 좌표 계산을 공유하는 데 있었다.
1
2
let normalized = span > 0 ? (value - range.min) / span : 0.5
return (height - verticalInset * 2) * (1 - normalized) + verticalInset
값이 크면 위로 간다. 심박도 케이던스도 고도도 그게 맞다. 그런데 페이스는 분/km라 숫자가 작을수록 빠르다. 의도해서 그렇게 그린 게 아니라 공통 규칙이 그대로 먹은 것이다.
그리고 이 앱에서는 어긋나는 데가 하나 더 있었다.
1
2
3
4
/// 목표 페이스보다 느림 (허용 오차 초과)
case sinkRate
/// 목표 페이스보다 빠름 (허용 오차 초과)
case overspeed
가라앉는다는 말을 느린 쪽에 붙여놓고 그림에서는 느릴수록 위로 올리고 있었다. 앱이 자기 말과 반대로 그리던 셈이다.
Lane에 플래그를 하나 두고 그 줄만 좌표를 뒤집었다.
1
2
3
let raw = span > 0 ? (value - range.min) / span : 0.5
let normalized = invertsAxis ? 1 - raw : raw
return (height - verticalInset * 2) * (1 - normalized) + verticalInset
목표 띠와 오차선, 짚은 점이 전부 이 함수를 지나서 한 자리만 뒤집으면 줄 전체가 따라온다. 띠를 그리는 쪽이 이미 abs()와 min()으로 위아래를 안 가리게 돼 있어서 두께도 그대로 남았다.
같이 뒤집은 설명 그림
i 버튼 안내에 실제 기록에서 잘라온 PACE 줄 사진 두 장이 들어 있다. 줄을 뒤집었으니 그 사진도 옛 방향이 된다.
수직으로 뒤집어 넣었다. 같은 표본에 새 계산을 돌린 것과 결과가 같아서 다시 찍은 것과 다르지 않다. 다만 Mission Flight 쪽 사진 아래에 다음 줄(BPM)의 윗부분이 조금 걸려 있어서, 그대로 뒤집으면 그게 위로 올라간다. 줄 경계를 찾아 잘라낸 뒤 뒤집었다.
문구도 하나 틀어졌다. “위로 솟았다 사라지는 자리”가 이제 아래가 된다. 그리고 안내에 한 항목을 새로 뒀다.
PACE 줄이 읽히는 방향
PACE만 위로 갈수록 빨라요. 페이스는 숫자가 작을수록 빠른 값이라, 나머지 세 줄과 반대로 그려요.
줄이 올라가면 빨라진 거고 내려가면 느려진 거예요. 목표보다 느려졌을 때 SINK RATE 경고가 뜨는 것과 같은 방향이에요.
둘째 줄을 붙인 이유가 있다. 첫 줄만 있으면 외워야 하는 규칙이 된다. 앱이 이미 쓰는 말과 이어주면 왜 그 방향인지가 설명이 된다.
40분 13초로 시작한 러닝
실기기 기록을 열었더니 첫 표본이 이랬다.
1
T+ 00:00:05 PACE 40:13 /km 0.01 km
5초에 10m를 갔으니 산술적으로는 8분 20초/km다. 40분 13초가 나오려면 그 순간 속도가 0.41m/s여야 한다. 거의 서 있는 속도다.
페이스는 1 ÷ 속도인데 그 속도가 이중 지수 이동평균을 두 번 거친 값이다. 서 있다가 뛰기 시작하면 그 평균이 실제 속도까지 올라오는 데 시간이 걸린다. 얼마나 걸리는지 계산해봤다. 서 있던 속도 0.41m/s에서 시작해 3m/s로 뛰는 경우다.
| 경과 | 그때 나오는 페이스 | 실제와의 차이 |
|---|---|---|
| 5초 | 14:10 /km | +155% |
| 10초 | 8:19 /km | +50% |
| 15초 | 6:27 /km | +20% |
| 20초 | 6:00 /km | +8% |
| 23초 | 5:46 /km | +5% |
20초는 지나야 쓸 만한 값이 된다.
턱에 걸려 있던 가드
이미 가드가 있었다.
1
2
let isWithinStartupGrace = timestamp.timeIntervalSince(runStartTime) < startupGracePeriod // 5.0
let pace = (isWithinStartupGrace || isStationary) ? 0 : flightData.pace
시작 5초 안의 표본은 페이스를 0으로 담는다. 0은 “기록 없음”이라 선이 비고 판독 패널에 --:--가 뜬다.
그런데 유예가 5초고 표본 간격도 5초다. 같은 숫자라 첫 표본이 유예가 끝나는 바로 그 자리에 떨어지고, < 5.0이라 안 걸린다. 그리고 위 표대로 5초는 애초에 한참 모자란다.
그래서 먼저 20초로 올렸다. 표가 20초를 가리키고 있었으니까.
시간으로 끊은 틀린 축
20초짜리를 넣고 다시 나가봤다. 왕복 2km를 걸으면서 기록을 열어보니 시작 부분은 깔끔했는데, 중간에 한 번 더 솟은 자리가 있었다. 신호등에서 멈췄다가 다시 걸은 지점이었다.
당연한 일이었다. 40분 13초를 만든 건 “러닝이 시작된 지 얼마 안 됐다”가 아니다. 스무딩 속도가 0 근처에서 올라오는 중이라는 것이다. 그 상태는 멈췄다 다시 갈 때마다 돌아온다. 러닝 한 번에 한 번만 생기는 일이 아니다.
시간으로 끊으면 그걸 못 잡는다. 시작 20초만 막고 나머지는 그대로 남는다. 반대로 멀쩡한 구간도 같이 버린다. 20초 안에 제대로 실린 표본이 있어도 날아간다.
거르는 기준을 시간에서 값으로 옮겼다. 믿을 수 없을 만큼 느린 페이스는 언제 나왔든 안 담는다.
1
2
let isTooSlowToTrust = flightData.pace >= untrustedPaceThreshold
let pace = (isWithinStartupGrace || isStationary || isTooSlowToTrust) ? 0 : flightData.pace
그 임계값을 얼마로 둘지가 남는데, 새 숫자를 만들지 않았다. 앱에 이미 “이 아래는 움직이는 게 아니다”라고 정해둔 속도가 있다.
1
2
3
4
5
6
/// 정지 판단 속도 임계값 (m/s). 이 아래는 걷기가 아니라 GPS 노이즈로 간주한다.
private let stationarySpeedThreshold: Double = 0.5
private var untrustedPaceThreshold: Double {
1 / (stationarySpeedThreshold * 60 / 1000)
}
0.5m/s를 페이스로 바꾸면 33분 20초/km다. 그러니까 이 가드는 “앱이 이미 정지로 보는 속도만큼 느린 페이스는 표본에 안 담는다”는 한 줄이 된다. 두 값이 같은 뜻을 각자 다른 단위로 말하고 있으니 숫자를 두 개 들고 있을 이유가 없다.
걷기가 걸릴까 걱정했는데 여유가 있다. 내가 마트까지 걸은 페이스가 19분 28초/km였고 그게 0.86m/s다. 임계값보다 한참 빠르다.
시작 5초 유예는 그대로 뒀다. 되돌린 게 아니라 역할이 달라서 남긴 것이다. 그쪽은 정지 오판을 막는 자리고, 길게 잡으면 시작하고 한참 서 있어도 멈춘 줄 모른다. 표본을 거르는 일은 이제 값이 한다.
화면에 실시간으로 뜨는 페이스는 안 건드렸다. 그 값은 GPWS 판정에도 쓰여서, 0으로 바꾸면 시작하자마자 OVERSPEED가 뜬다.
말없이 사라지던 선
세로 폭은 최저~최고가 아니라 값들이 보통 모여 있는 범위로 잡는다. 가장 느린 값에 맞추면 잠깐 걷거나 멈춘 구간 하나 때문에 나머지가 전부 납작해지기 때문이다.
그래서 범위를 벗어난 값은 줄 밖으로 나가며 사라진다. 문제는 기록이 아예 없어서 끊긴 것과 화면에서 똑같아 보인다는 것이었다. 둘은 전혀 다른 일이다. 앞은 기록이 없는 것이고 뒤는 기록은 있는데 너무 벗어난 것이다.
선을 줄 끝에 눌러 붙이는 건 전에 한 번 해보고 버렸다. 그러면 정상적으로 끝 근처에 있는 값과 모양이 같아져서, 목표에서 2분이나 벗어난 페이스가 오차선에 걸친 것처럼 보인다.
그래서 선은 그대로 내보내되 나간 자리의 가장자리에 2pt 띠를 남기기로 했다.
1
2
let y = yPosition(for: value, range: range, height: size.height)
let outside = isAbove ? y < 0 : y > size.height
값이 아니라 화면 좌표로 판단한다. PACE 줄은 뒤집혀 있어서 느린 값이 아래로 나간다. 값으로 판단하면 위아래가 바뀐다.
기록이 없는 구간에는 띠를 안 그린다. 그걸 가르는 게 목적이니까.
조금 넓힌 범위
저장된 기록 15건 13,957개 표본으로 배수를 바꿔가며 재봤다.
| 배수 | 줄 안에 드는 표본 | 가장 많이 쓴 구간이 차지하는 세로 폭 |
|---|---|---|
| ×3 (전) | 94.9% | 33.6% |
| ×4 | 97.7% | 25.2% |
| ×5 | 98.5% | 20.2% |
| 최저~최고 | 100.0% | 13.2% |
×4로 갔다. 사라지는 양이 5.1%에서 2.3%로 절반 아래가 되는데 또렷함은 3/4만 내준다. 전부 담는 쪽은 나머지 2.3%를 없애려고 가장 오래 머문 구간을 2.5배 납작하게 만든다. 거래가 안 맞는다.
만지는 쪽에 공간을 주기
마지막은 배치다. 코스 그림과 네 줄이 남는 세로 공간을 반씩 나눠 갖고 있었다.
1
2
CourseTrackView(...)
.frame(minHeight: 140, maxHeight: .infinity) // 끝까지 자란다
코스는 보기만 하는 그림이고 네 줄은 손으로 짚는 것이다. 안내 문구에도 그렇게 적어뒀었다. 시점을 옮기는 건 아래 그래프에서만 한다고.
그러면 공간은 짚는 쪽이 가져가는 게 맞다. 코스를 200pt에서 끊었다.
1
.frame(minHeight: 140, maxHeight: 200)
작은 화면에서는 어차피 최소 높이 근처라 전과 같고, 큰 화면에서만 아래가 넓어진다.
한 가지는 분명히 해둬야 한다. 이건 잘리는 양을 안 바꾼다. 세로 범위는 표본이 정하지 화면 크기가 정하지 않는다. 줄이 커져도 밖으로 나가는 값은 그대로고, 안에 든 값이 더 넓게 펴질 뿐이다. 주석에도 그렇게 적어뒀다. 나중에 이 줄을 보고 “그래서 잘림이 해결됐나” 하고 헷갈리지 않게.
경고는 울리는데 안 울리던 1km 진동
아이폰에서 러닝을 시작하면 워치가 같이 돈다. 그런데 1km를 채워도 손목이 안 울렸다.
받은 지적은 이 모양이었다. 아이폰에서 시작해도 워치에서 GPWS 경고는 울린다. 그러면 1km 진동도 똑같이 울려야 하는 거 아니냐.
맞는 추론이다. 경고가 울렸다는 건 워치 앱이 깨어 있고 메시지가 닿는다는 증거다. 연결도 햅틱도 멀쩡하다는 뜻이다.
그런데 둘은 같은 조건이 아니었다. 경고는 워치가 스스로 울리고, 1km 진동은 아이폰이 울려달라고 부탁하고 있었다.
경고 쪽을 보면 이렇다. 워치는 gpwsStatus가 담긴 데이터를 받고, 그걸 보고 경고 화면으로 넘어간다. 진동은 그 화면이 만든다.
1
2
3
4
5
6
hapticTask = Task {
while !Task.isCancelled { // 경고가 풀릴 때까지 계속 돈다
WKInterfaceDevice.current().play(pattern.type)
try? await Task.sleep(for: .seconds(2.0))
}
}
그리고 그 데이터는 초당 한 번 온다. 한 번 떨어뜨려도 1초 뒤에 같은 상태가 또 온다. 화면은 그대로 떠 있고 진동은 계속 돈다.
1km 진동은 1km에 딱 한 번 가는 단발 메시지였다.
1
2
3
4
func sendSplitReached(_ order: Int) {
guard session.isReachable else { return }
session.sendMessage(message, replyHandler: nil) // errorHandler 도 없다
}
replyHandler도 errorHandler도 없어서 실패하면 조용히 사라진다. 그리고 다음 기회는 1km 뒤다.
그러니까 경고가 울리는 것은 1km 진동이 울려야 한다는 근거가 못 된다. 하나는 초당 한 번씩 저절로 재시도되는 구조고, 하나는 1km에 한 번 쏘고 잊는 구조다. 연결이 멀쩡해도 그 1초에 워치 앱이 백그라운드로 내려가 있으면 그 km는 날아간다.
워치가 알아낼 수 있는 진동
고칠 방향은 메시지를 더 튼튼하게 보내는 게 아니었다. 경고와 같은 구조로 만드는 것이다.
워치는 이미 필요한 걸 받고 있다.
1
let distance = message["distance"] as? Double ?? 0
초당 한 번 누적 거리가 온다. 그걸로 km를 넘었는지 워치가 직접 보면 된다.
1
2
3
4
5
6
func checkSplitHaptic(distance: Double) {
let km = Int(distance / 1000)
defer { lastSplitKm = km }
guard let lastSplitKm, km > lastSplitKm else { return }
playSplitHaptic()
}
한 표본을 놓쳐도 다음 표본에서 같은 구간이 다시 잡힌다. 경고가 안 끊기는 이유를 그대로 얻는다.
울릴 함수는 새로 만들 게 없었다. 워치 단독 러닝에서는 원래 이걸로 울리고 있었다.
1
2
3
4
for await split in await runningCenter.streamSplitAnnouncements() {
self.playSplitHaptic()
self.speechAnnouncerService.announce(split: split)
}
아이폰 주도 러닝에서는 워치가 TakeoffView를 지나가지 않는다. 바로 비행 화면으로 던져진다. 그래서 GPS를 켜는 자리를 안 지나가고, 거리를 쌓지 않아 저 스트림이 끝까지 아무것도 안 내보냈다. 없던 건 진동이 아니라 트리거였다.
첫 표본에서는 안 울리게 뒀다. 워치가 러닝 도중에 합류하면 거리가 0에서 바로 뛰어올라, 지나간 적 없는 구간을 울리는 셈이 된다.
이제 아이폰이 쏘던 메시지는 지웠다. 안 지우면 같은 km에 두 번 울린다.
워치가 알아낼 수 없는 음성
같은 자리에서 음성도 나온다. 그건 못 옮겼다.
1
2
3
var sentence = "Kilometer \(split.order), pace \(paceText)"
if split.heartRate > 0 { sentence += ", heart rate \(split.heartRate)" }
if split.cadence > 0 { sentence += ", cadence \(split.cadence)" }
구간 평균 페이스, 심박, 케이던스가 필요하다. 미러링에서 워치는 그걸 안 쌓는다. 초당 오는 데이터는 그 순간값이라 구간값이 아니다. 문장을 지어낼 재료가 없다.
그래서 음성은 아이폰이 값을 보내준다. 메시지 하나가 다시 생긴 셈인데, 아까 지운 것과 사정이 다르다. 진동은 워치가 가진 거리로 알아낼 수 있었으니 메시지가 군더더기였다. 값은 알아낼 수 없다.
그리고 갈라놓은 게 오히려 낫다.
| 전달 | 떨어지면 | |
|---|---|---|
| 진동 | 받은 거리로 워치가 직접 판정 | 다음 초 값에서 다시 잡힌다 |
| 음성 | 아이폰이 구간값을 보냄 | 그 구간 음성만 날아간다. 진동은 울린다 |
놓치면 안 되는 쪽은 복구되게 두고, 놓쳐도 되는 쪽만 보낸다. 음성이 한 번 빠져도 손목이 울렸으니 km를 지난 건 안다.
이어폰이 꽂힌 쪽이 정하는 읽는 기기
음성을 아이폰이 읽게 두면 걸리는 데가 있다. 워치에 이어폰을 꽂고 워치로 노래를 들으며 뛰는 경우다. 그러면 아이폰 음성은 주머니 안에서 혼자 떠든다.
둘 다 읽게 둘 수는 없다. 이어폰이 아이폰에 있으면 워치가 손목 스피커로 떠들고, 워치에 있으면 아이폰이 주머니에서 웅얼거린다. 어느 쪽이든 겹친다.
처음에는 설정에 선택을 뒀다. 기본값은 아이폰이다.
사용자가 모르는 걸 묻던 설정
실기기에서 뛰어보니 설정을 아이폰으로 둬도 음성이 날 때가 있고 안 날 때가 있었다.
에어팟은 한 번에 한 기기에만 붙는다. 그리고 러닝 중에도 아이폰과 워치 사이를 저절로 옮겨 다닌다. 그러면 이렇게 된다.
- 이어폰이 워치에 있는데 설정이 아이폰 → 아이폰이 자기 스피커로 읽는다. 주머니 속이라 안 들린다
- 이어폰이 아이폰으로 넘어갔는데 설정이 워치 → 워치가 손목 스피커로 읽는다. 노래에 묻힌다
둘 다 “소리가 안 난다”로 보이고, 왔다 갔다 하니까 “날 때도 있고 안 날 때도 있고”가 된다.
설정을 고르게 한 것 자체가 틀렸다. 지금 이어폰이 어디 붙어 있는지는 사용자도 모른다. 본인이 옮긴 게 아니라 기기끼리 넘긴 것이기 때문이다.
그래서 묻지 않기로 했다. 기기가 자기 출력을 직접 보면 된다.
1
2
3
4
5
6
7
var hasBluetoothOutput: Bool {
AVAudioSession.sharedInstance().currentRoute.outputs.contains {
$0.portType == .bluetoothA2DP
|| $0.portType == .bluetoothHFP
|| $0.portType == .bluetoothLE
}
}
currentRoute로 소리가 어디로 나가는지 보기
currentRoute는 지금 이 순간 소리가 들어오고 나가는 길이다. 기기에 무엇이 연결돼 있느냐가 아니라, 시스템이 실제로 고른 경로다. 에어팟을 꽂아도 다른 기기가 쥐고 있으면 여기 안 뜬다.
outputs가 나가는 쪽 목록이고, 각 항목의 portType이 종류다. 블루투스가 셋으로 갈리는데 쓰임이 다르다. A2DP 는 음악용 고음질 단방향, HFP 는 통화용 양방향, LE 는 저전력이다.
셋을 다 적어야 하는 게 함정이다. 하나라도 빠뜨리면 그 종류로 연결된 이어폰을 못 알아본다. 뒤에서 유선 이어폰을 놓친 것도 같은 모양의 실수다.
아이폰이 쥐고 있으면 아이폰이 읽고, 아니면 워치로 넘긴다. 넘겨받은 워치도 같은 걸 보고 자기가 읽을지 정한다. 설정은 지웠다.
노래만 작아지고 안 나오던 음성
워치에 에어팟을 붙이고 노래를 켠 채로 걸었다. 1km에서 노래 볼륨이 잠깐 내려갔다 돌아왔고, 음성은 나오지 않았다.
워치 기록을 꺼내보니 네 줄이 전부였다.
1
2
3
4
07:05:42.148 [split.watch.voice] 구간=1 수신
07:05:42.264 [split.audio.ok] 출력=Speaker 다른소리=false
07:05:42.270 [split.voice.declined] 구간=1 이어폰 없음
07:05:42.271 [split.watch.haptic] 구간=0->1 거리=1002m
에어팟으로 노래가 나오는 중인데 출력=Speaker로 잡혔다.
볼륨이 내려간 건 우리 코드다. 읽기 직전에 오디오 세션을 켜면서 다른 소리를 낮추는데(.duckOthers), 바로 다음 줄에서 “이어폰이 없네” 하고 세션을 되돌렸다. .264와 .270 사이 6밀리초가 그 전부다. 음성은 시작도 안 했다.
이걸 미리 걱정하고 적어뒀었다. 연결만 돼 있고 쉬고 있는 이어폰이 경로로 안 잡힐 수 있으니 세션을 먼저 켜고 보면 된다고. 안 괜찮았다. 쉬고 있던 것도 아니고 그걸로 노래가 나오는 중이었는데도 안 잡혔다.
watchOS에서 currentRoute는 우리 앱 세션의 경로다. 다른 앱이 출력을 쥐고 있으면 그걸 비춰주지 않는다. 말하기 전에 물어볼 방법이 없다.
못 묻는 것을 묻지 않기
워치 쪽 판단을 들어냈다. 넘어온 구간은 그냥 읽는다.
판단은 아이폰에서만 한다. iOS에서는 같은 코드가 제대로 돌았다. 같은 날 8km 러닝에서 아이폰이 1km부터 8km까지 전부 자기가 읽었고, 그 구간에는 워치로 넘긴 기록이 한 줄도 없다.
| 이어폰 | 읽는 쪽 |
|---|---|
| 아이폰 | 아이폰이 이어폰으로 |
| 워치 | 워치가 이어폰으로 |
| 없음 | 워치가 손목 스피커로 |
| 워치가 안 닿음 | 아이폰이 대신 |
세 번째 줄은 아이폰 스피커로 보내고 싶었다. 손목보다 아이폰이 나을 것 같았다. 그런데 “이어폰이 워치에 있다”와 “아무 데도 없다”를 가를 수 있는 건 워치뿐이고, 그걸 못 묻는 게 지금 이 문제다. 못 가르는 걸 가르려다 두 번째 줄까지 망가져 있었다.
마지막 줄은 되묻지 않고 얻는다. 아이폰은 워치가 닿는지 확인하지 않고 그냥 보낸다. 전달이 실패하면 errorHandler로 돌아오니까, 그때 아이폰이 읽는다. “지금 닿나”를 미리 묻는 것보다 “보내보고 안 가면 받는” 쪽이 정확하다. 묻는 시점과 보내는 시점 사이에도 상태는 바뀐다.
이번에 못 알아본 유선 이어폰
아이폰 판단은 멀쩡하다고 생각했다. 에어팟으로는 제대로 돌았으니까.
며칠 뒤 유선 이어폰을 아이폰에 꽂고 걸었더니 손목에서 소리가 났다. 11km를 걷는 동안 열한 구간이 전부 같은 모양이었다.
1
2
아이폰 split.phone.voice 구간=N 워치닿음=true ← 나는 이어폰 없다며 넘김
워치 split.audio.ok 출력=Speaker ← 손목 스피커로 읽음
내가 쓴 검사는 이랬다.
1
2
3
$0.portType == .bluetoothA2DP
|| $0.portType == .bluetoothHFP
|| $0.portType == .bluetoothLE
유선 이어폰은 .headphones다. 블루투스가 아니라서 통째로 빠졌다. USB-C 이어폰도, 카플레이도 같은 자리에 걸린다.
빠진 종류를 하나씩 더할 수도 있었다. 그런데 더하다 보면 다음에 또 하나가 나온다. 질문을 잘못 잡은 것이다. 알아야 할 건 “블루투스냐”가 아니라 “소리가 내 스피커가 아닌 다른 데로 나가고 있냐”다.
1
$0.portType != .builtInSpeaker && $0.portType != .builtInReceiver
고르는 목록을 만들지 않고 반대로 적었다. 더해야 할 게 안 생긴다.
첫 러닝에서만 되던 음성
그 11km 로그를 보다가 더 큰 걸 찾았다.
같은 날 1km를 한 번 걷고 끝낸 뒤, 앱을 끄지 않고 바로 다시 시작해 11km를 걸었다. 두 번째 러닝에서는 스플릿 분기가 1km부터 11km까지 한 번도 안 돌았다. 진동은 열한 번 다 울렸는데 음성 쪽만 죽어 있었다.
둘 다 같은 RunningCenter에서 나오는데 한쪽만 멈춘 것이다.
1
2
3
4
5
private func clearContinuation() {
continuation = nil
phaseContinuation = nil
splitAnnouncementContinuation = nil
}
정리 함수 하나가 세 스트림을 전부 비운다. 그런데 세 스트림이 각자의 onTermination에 이걸 똑같이 물려놨다. 하나가 끝나면 셋 다 죽는다.
등록 시점이 다른 게 문제였다.
| 스트림 | 등록 |
|---|---|
| flightData | 러닝마다 |
| phase | 앱 뜰 때 한 번 |
| splitAnnouncements | 앱 뜰 때 한 번 |
러닝이 끝나면 flightData가 끝나면서 셋을 다 비운다. 다음 러닝은 flightData만 다시 등록한다. 스플릿은 영영 nil로 남는다.
로그를 날짜순으로 늘어놓으니 규칙이 한눈에 보였다.
| 러닝 | 음성 분기 |
|---|---|
| 앱 띄운 뒤 첫 러닝 | 돌았다 |
| 앱 띄운 뒤 첫 러닝 | 돌았다 |
| 바로 이어서 시작한 8km | 한 번도 안 돌았다 |
| 앱 띄운 뒤 첫 러닝 | 돌았다 |
| 바로 이어서 시작한 11km | 한 번도 안 돌았다 |
이게 “음성이 나올 때도 있고 안 나올 때도 있다”의 정체였다. 앱을 다시 켜면 돌아온 것도 설명된다. init()이 다시 돌면서 재등록되기 때문이다.
스트림마다 자기 것만 비우게 갈랐다. 그리고 옛 스트림이 늦게 정리되면서 방금 등록한 것을 지우는 것도 세대 번호로 막았다. 고친 뒤 같은 조건에서 11km를 다시 걸었더니 열한 구간이 전부 찍혔다.
정리
전부 1.4에 이미 있던 것들이다. 20km를 넘겨 뛰어야, 네 줄을 나란히 놓고 봐야, 멈췄다 다시 걸어봐야, 워치에 이어폰을 꽂고 나가봐야 보이는 것들이었다.
고치면서 되풀이된 게 하나 있다. 이미 앱 안에 있는 것에 붙이면 설명이 되고, 새로 만들면 외워야 하는 규칙이 된다.
페이스 줄 방향을 안내할 때 “위로 갈수록 빠르다”만 적으면 외우는 수밖에 없다. 느린 쪽에 SINK RATE라는 이름을 이미 붙여뒀으니 거기 이으면 왜 그 방향인지가 설명된다. 믿을 수 없는 페이스를 거르는 임계값도 새 숫자를 안 만들었다. “이 아래는 움직이는 게 아니다”라고 정해둔 속도가 있었고, 그걸 단위만 바꾸면 33분 20초/km였다. 1km 진동도 그렇다. 울릴 함수는 워치 단독 러닝에서 쓰던 게 이미 있었고, 판정할 거리는 초당 한 번씩 이미 도착하고 있었다.
반대로 없던 걸 알아내지는 못한다. 구간 평균 페이스와 심박은 미러링 중인 워치에 애초에 없는 값이라, 음성만은 아이폰이 보내주는 수밖에 없었다. 거기서 진동과 음성의 성격이 갈렸다.
그리고 하나 더. 기기에 물어볼 수 있다고 미리 가정하지 말아야 한다. 워치에 “지금 이어폰 있니”를 묻는 코드는 아무 경고 없이 빌드됐고, 틀린 답을 또박또박 돌려줬다. 노래가 나오는 중에도 “없다”고 했다. 안 된다는 걸 알려준 건 컴파일러도 시뮬레이터도 아니고 노래 볼륨이 6밀리초 내려갔다 올라온 것이었다.
같은 증상 뒤의 세 가지
“구간 음성이 안 나온다” 하나로 보이던 것이 결국 셋이었다.
| 고친 방식 | |
|---|---|
| 워치가 자기 이어폰을 못 알아본다 | 묻지 않는다 |
| 아이폰이 유선 이어폰을 못 알아본다 | 질문을 뒤집는다 |
| 두 번째 러닝부터 스트림이 죽는다 | 정리 범위를 갈라놓는다 |
앞의 둘을 고치고 “이제 됐다”고 생각한 자리에서 세 번째가 나왔다. 첫 러닝만 되고 있었으니 둘을 고쳤어도 두 번째 러닝부터는 여전히 조용했을 것이다.
셋 다 같은 자국을 남겼다. 그래서 하나로 보였고, 고칠 때마다 “이번엔 됐나” 를 실기기에서 다시 확인해야 했다. 증상이 같다고 원인이 하나인 건 아니다.
그리고 세 번째는 눈으로는 못 찾았을 것이다. 날짜별 로그를 늘어놓고 된 러닝과 안 된 러닝을 나란히 세워본 뒤에야 “앱을 새로 띄운 뒤 첫 러닝만 된다”가 보였다. 한 번의 기록으로는 보이지 않는 규칙이었다.


