RunWay 1.4 (1) 안 쓰던 값 셋, 없던 값 하나
1.3.2를 심사에 넣고 1.4를 시작했다. 할 일이 여덟 가지쯤 되는데, 그중 하나만 성격이 달라서 순서를 그것부터 잡았다.
1.3.3을 1.4에 합친 이유
원래는 작은 버전을 하나 끼워 넣을 생각이었다. 이유는 딱 하나였다.
좌표에 시각을 저장하는 작업은 미루는 만큼 그대로 잃는다. 나중에 필드를 추가해도 그 전에 저장된 기록에는 값이 없다. 나중에 채워 넣을 방법이 아예 없다.
나머지는 미뤄도 손해가 없다. 미러링 신뢰성도, 업데이트 알림도 언제 하든 같은 일이다. 그런데 이것만 하루 미룰 때마다 그날 뛴 기록이 영영 비어버린다.
그래서 “이것만 먼저 내자”였는데, 1.4를 한 달 안에 낼 계획이라 접었다. 한 달치면 감수할 만하고, 버전을 하나 더 내면 심사도 한 번 더다. 대신 1.4 작업 중에서 이걸 제일 먼저 했다.
좌표에 시각이 없던 문제
저장하는 모델이 이렇게 생겼었다.
1
2
3
4
5
6
@Model
class SwiftDataCoordinate {
var latitude: Double = 0
var longitude: Double = 0
var order: Int = 0
}
위도, 경도, 순서. 지도에 선을 긋는 데는 이걸로 충분하다. 그런데 경로 중간의 한 지점을 짚어서 “여기서 몇 분 페이스였나”를 물으면 답할 수가 없다.
순서가 있으니 알 수 있지 않나 싶은데, 안 된다. 저장하기 직전에 좌표를 솎아내기 때문이다.
지도에 그릴 정밀도만 있으면 되니까 곡선을 유지하는 데 필요한 점만 남기고 버린다. 직선 구간은 양 끝만 남고 가운데가 통째로 사라진다. 그래서 남은 좌표들 사이의 시간 간격이 제각각이다. 1번과 2번 사이는 3초인데 2번과 3번 사이는 40초일 수 있다.
순서는 남은 점들의 나열 순서일 뿐, 얼마나 걸렸는지는 아무것도 말해주지 않는다.
시각 하나로 되는 이유
페이스를 따로 저장할까 했는데 그럴 필요가 없었다. 좌표 두 개 사이의 거리는 위도·경도로 계산되고, 시간 차이만 알면 나눠서 속도가 나온다.
1
2
/// 러닝 시작부터 이 좌표를 수집한 시점까지의 경과 시간 (초).
var elapsedTime: Int = 0
선언부에 기본값을 둔 건 SwiftData 때문이다. 자동 마이그레이션이 init 파라미터가 아니라 이 자리의 값을 보고 기존 레코드를 채운다. 1.1에서 한 번 밟은 함정이라 이번엔 처음부터 이렇게 뒀다.
값을 담는 쪽은 이미 필요한 게 다 있었다.
1
2
3
4
5
6
7
// RunningCenter
let elapsed = runStartTime.map { Int(location.timestamp.timeIntervalSince($0)) } ?? 0
coordinateArray.append((
latitude: location.coordinate.latitude,
longitude: location.coordinate.longitude,
elapsedTime: max(0, elapsed)
))
위치가 들어올 때마다 location.timestamp가 같이 오고, 러닝 시작 시각도 같은 객체 안에 이미 있었다. 새로 계산할 게 없었다.
솎아낸 뒤 짝이 어긋나는 자리
막힌 건 여기였다. 좌표를 솎아내는 함수가 이렇게 생겼다.
1
static func simplify(_ coordinates: [(latitude: Double, longitude: Double)]) -> [(latitude: Double, longitude: Double)]
좌표만 넣고 좌표만 받는다. 남은 좌표가 원래 몇 번째였는지 알 수가 없으니, 시각 배열과 짝을 맞출 방법이 없다.
세 가지를 생각했다.
- 좌표 타입 자체에 시각을 넣어서 통째로 넘긴다
- 시각도 같이 받는 함수를 따로 만든다
- 남은 좌표의 원래 번호를 돌려받는다
3번으로 갔다. 함수 안을 열어보니 이미 번호로 계산하고 있었다.
1
2
let keptIndices = simplifiedIndices(points, tolerance: tolerance)
return keptIndices.map { coordinates[$0] } // 번호를 좌표로 바꿔서 돌려주고 있었다
번호를 좌표로 바꿔서 돌려주는 그 마지막 줄 때문에 정보가 사라지고 있었다. 그래서 번호를 그대로 돌려주는 입구를 하나 더 냈다.
1
2
3
4
5
static func keptIndices(of coordinates: [(latitude: Double, longitude: Double)]) -> [Int]
static func simplify(_ coordinates: [(latitude: Double, longitude: Double)]) -> [(latitude: Double, longitude: Double)] {
keptIndices(of: coordinates).map { coordinates[$0] }
}
기존 함수는 새 함수 위에 한 줄로 남겼다. 쓰던 곳을 안 건드려도 된다.
저장하는 쪽은 이렇게 된다.
1
2
3
4
5
6
7
8
9
10
11
12
let keptIndices = PolylineSimplifier.keptIndices(
of: coords.map { (latitude: $0.latitude, longitude: $0.longitude) }
)
runningData.coordinates = keptIndices.enumerated().map { order, index in
let coord = coords[index]
return SwiftDataCoordinate(
latitude: coord.latitude,
longitude: coord.longitude,
order: order,
elapsedTime: coord.elapsedTime
)
}
화면에 쓰는 값을 안 건드린 이유
좌표를 담는 배열에 시각이 들어가니 그 배열을 쓰는 곳이 전부 걸렸다. 그중 하나가 러닝 중 화면에 경로를 그리는 버퍼다.
거기까지 바꾸면 NavDisplayForeground와 PFDView까지 번진다. 그런데 지도에 선을 긋는 데는 시각이 필요 없다. 넘길 때 떼기로 했다.
1
2
3
// 화면에 경로만 그리므로 시각은 떼고 넘긴다.
coordinateBuffer = await runningCenter.coordinateArray
.map { (latitude: $0.latitude, longitude: $0.longitude) }
값이 더 있다고 다 넘길 이유는 없다. 안 쓰는 쪽에 넘기면 그쪽 타입까지 따라 바뀐다.
워치 전송 쪽 수정
워치 단독으로 뛴 기록은 아이폰으로 넘어온다. 그때 좌표를 이렇게 싣고 있었다.
1
.map { [$0.latitude, $0.longitude] }
숫자 두 개짜리 배열이다. 세 번째로 시각을 넣었는데, 받는 쪽을 조금 느슨하게 뒀다.
1
2
guard coord.count >= 2 else { return nil }
let elapsed = coord.count >= 3 ? Int(coord[2]) : 0
두 앱은 같이 올라가지만 워치만 늦게 업데이트되는 짧은 구간이 생길 수 있다. 그때 두 개짜리가 와도 기록이 통째로 버려지지 않게 했다.
이전 기록의 한계
채울 방법이 없다. 그래서 읽는 쪽에서 0이면 “구간 페이스 없음”으로 다뤄야 한다.
화면은 아직 안 만들었다. 저장만 먼저 해둔 것이고, 경로를 드래그해서 페이스를 보는 화면은 1.4 후반에 붙인다. 오늘 뛴 러닝부터는 값이 들어간다.
월 합계에 빠져 있던 총 러닝 시간
캘린더 화면 위쪽에 그 달 요약이 세 칸 있다. 거리, 횟수, 평균 페이스.
얼마나 오래 뛰었는지가 없었다. 기록마다 시간을 이미 갖고 있는데도.
한 칸 더 넣는 건 쉬운데 표기가 걸렸다. 기존에 쓰던 형식이 00:06:12인데 여덟 글자다. 세 칸이 네 칸이 되면 한 칸이 115pt에서 84pt로 줄고, 작은 기기에서는 80pt다. 글꼴이 넓은 편이라 안 들어간다.
1
2
3
4
5
6
/// - Returns: `"6:12"` 형식의 문자열
static func secondToHourMinute(_ second: Int) -> String {
let minutes = (second / 60) % 60
let hours = second / 3600
return String(format: "%d:%02d", hours, minutes)
}
네 글자로 줄였다. 월 합계에서 초는 의미가 없다. 100시간이 넘어도 여섯 글자라 여유가 있다.
두 줄로 나누는 것도 생각했는데 안 된다. 이 화면은 스크롤을 막아둔 화면이라, 세로가 64pt 늘면 작은 기기에서 달력 아랫부분이 잘린다.
네 번째 칸이 이번에 넣은 것이다. 8:55 가 8시간 55분인데, 여기에 00:08:55 를 넣으면 칸을 넘는다.
로그북에 빠져 있던 끝난 시각
목록의 각 줄이 날짜만 보여주고 있었다. 같은 날 두 번 뛰면 거리 빼고는 똑같아 보인다.
1
2
3
4
5
// 전
Text(flight.date.formatted(date: .abbreviated, time: .omitted))
// 후
Text(flight.date.formatted(date: .abbreviated, time: .shortened))
한 단어 바꾸는 것으로 끝났다. date가 기록을 저장하는 시점, 즉 러닝을 끝낸 시각이라 값은 이미 있었다.
없어서 못 보여준 게 아니라 안 보여주고 있었던 것이다. 이런 게 생각보다 많다.
10월 5일 하나에 일곱 줄이 있다. 날짜만 뜨던 때라면 거리 말고는 어느 게 어느 러닝인지 알 수가 없었다.
새 러닝에 지난 러닝 값이 보이던 문제
1.3.2 검증하다가 발견한 것이다.
워치에서 러닝을 끝내고 앱을 백그라운드로 보낸 뒤 아이폰에서 바로 새 러닝을 시작하면, 워치 화면에 직전 러닝의 거리와 페이스가 잠깐 보인다.
미러링에서 워치의 값은 아이폰이 보내주는 것으로만 갱신된다. 3초 간격이라 첫 데이터가 올 때까지는 이전 값이 그대로 남는다.
기록에는 영향이 없고 몇 초 뒤 알아서 맞춰진다. 그래서 1.3.2를 막지는 않았다. 다만 하나 걸리는 게 있었다. 이전 러닝이 경고 상태로 끝났다면 새 러닝 시작 직후에 진동이 한 번 울린다.
고치는 자리는 이미 있었다. 1.3.2에서 화면 경로를 갈아끼우게 만든 그 줄 옆이다.
1
2
3
4
5
6
7
if result.startOrigin == .remote {
self.flightData = FlightData()
self.elapsedTime = 0
self.isPaused = false
self.navigationPath = [.pfd]
}
정리
네 가지를 했는데 셋이 “값은 이미 있는데 안 쓰고 있던 것”이었다.
| 있던 것 | 안 하던 것 | |
|---|---|---|
| 로그북 시각 | date에 종료 시각 | 화면에 안 보여줌 |
| 월 총 시간 | 기록마다 time | 합산을 안 함 |
| 워치 이전 값 | 비우는 자리 | 비우지를 않음 |
나머지 하나인 좌표 시각만 정말로 없던 값이었고, 그래서 그것만 시기가 걸렸다.
이미 있는 걸 안 쓰는 건 언제 고쳐도 같은 일이다. 없는 걸 안 만들어두면 그 사이가 영영 빈다. 순서를 정할 때 이 둘을 구분하는 게 생각보다 중요했다.

