mycode/myApp/HaruDanim/Shared/HealthMetric.swift
songyc macbook 2ae469d187 perf(1.5-p11): 등가 성능 최적화 — 건강 캐시 인메모리·백필 스위프·게이지 버킷화·위젯 타임라인, 빌드 9
소킹 중 '1.4 대비 버벅임 증가' 보고의 원인 수정 (동작·수치 완전 불변):
- HealthCache/HealthIntervals 인메모리 캐시(세대 카운터 무효화) + workoutKeys 이중 load 제거
- DayMath.calendar 저장 프로퍼티화 (접근마다 Calendar 사본 생성 제거)
- 수면 백필 O(400일×샘플수) → 정렬+포인터 스위프 (퍼즈 3,000회 등가 증명)
- refreshRecent 캐시 기록 배칭 (일수×2회 → 총 2회)
- QuestProgress.spanValue/spanRawValue 하루 버킷화 (게이지의 미래 기록 즉시 반영 스펙 보존)
- 위젯 라이브 타임라인 makeEntry refresh 파라미터 (엔트리 7개×컨테이너 오픈 → 1회)
- 일기 건강 카드 칩 value() 이중 호출 제거

검증: 옛/새 빌드 A/B — 덤프 3종 값 동일·4화면 픽셀 동일, 자가 검증 62/20/27/10
ALL PASS(26.5+18.5), 벌크 1만 건 렌더 정상, 3종 빌드 그린, 카탈로그 무변경.
whats-new-1.5 3언어에 성능 개선 줄 추가.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WtZwRRjbtM9pZJDM4FkTq6
2026-08-26 14:23:56 +09:00

463 lines
20 KiB
Swift
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// HealthMetric.swift
// Haru_Danim
//
// (1.5 Docs/plan-1.5.md §3)
// - · (HealthKit )
// - : (··· )=, =, =kcal,
// =m, =, =ml. HealthKit ·· ·
// - HealthCache: × App Group (HealthStore),
// · ·QuestProgress ( 5.1.3 CloudKit )
//
import Foundation
nonisolated enum HealthMetric: String, CaseIterable, Identifiable, Codable {
case steps, exerciseMinutes, sleep, activeEnergy, distance, standHours, mindfulMinutes, water
var id: String { rawValue }
var name: String {
switch self {
case .steps: return String(localized: "걸음 수")
case .exerciseMinutes: return String(localized: "운동 시간")
case .sleep: return String(localized: "수면")
case .activeEnergy: return String(localized: "활동 에너지")
case .distance: return String(localized: "이동 거리")
case .standHours: return String(localized: "일어서기")
case .mindfulMinutes: return String(localized: "마음챙김")
case .water: return String(localized: "물 섭취")
}
}
var symbolName: String {
switch self {
case .steps: return "figure.walk"
case .exerciseMinutes: return "figure.run"
case .sleep: return "bed.double.fill"
case .activeEnergy: return "flame.fill"
case .distance: return "map.fill"
case .standHours: return "figure.stand"
case .mindfulMinutes: return "brain.head.profile"
case .water: return "drop.fill"
}
}
/// ( ) ·
var isDuration: Bool {
switch self {
case .exerciseMinutes, .sleep, .mindfulMinutes: return true
default: return false
}
}
/// (·· )
func valueLabel(_ value: Double) -> String {
switch self {
case .steps:
return String(localized: "\(Int(value).formatted())")
case .exerciseMinutes, .sleep, .mindfulMinutes:
return Format.durationShort(value)
case .activeEnergy:
return String(localized: "\(Int(value).formatted())kcal")
case .distance:
let km = value / 1000
let text = km.formatted(.number.precision(.fractionLength(km >= 10 ? 0 : 1)))
return String(localized: "\(text)km")
case .standHours:
return String(localized: "\(Int(value))시간")
case .water:
// ml L
let liters = value / 1000
let text = liters.formatted(.number.precision(.fractionLength(0...2)))
return String(localized: "\(text)L")
}
}
/// (·· )
static let defaultTileMetrics: [HealthMetric] = [.steps, .exerciseMinutes, .sleep]
/// ( )
static func selectedList(raw: String) -> [HealthMetric] {
let parsed = raw.split(separator: ",").compactMap { HealthMetric(rawValue: String($0)) }
return parsed.isEmpty ? defaultTileMetrics : parsed
}
}
// MARK: - ( , plan §3.4)
/// (20). HKWorkoutActivityType API
/// . = + HealthStore + 3.
nonisolated enum HealthWorkoutKind: String, CaseIterable, Identifiable {
case running, walking, cycling, swimming, strength, yoga, pilates, hiking
case soccer, baseball, basketball, tennis, badminton, golf
case jumpRope, boxing, dance, stretching, hiit, other
var id: String { rawValue }
var name: String {
switch self {
case .running: return String(localized: "달리기")
case .walking: return String(localized: "걷기 운동")
case .cycling: return String(localized: "사이클")
case .swimming: return String(localized: "수영")
case .strength: return String(localized: "근력 운동")
case .yoga: return String(localized: "요가")
case .pilates: return String(localized: "필라테스")
case .hiking: return String(localized: "등산")
case .soccer: return String(localized: "축구")
case .baseball: return String(localized: "야구")
case .basketball: return String(localized: "농구")
case .tennis: return String(localized: "테니스")
case .badminton: return String(localized: "배드민턴")
case .golf: return String(localized: "골프")
case .jumpRope: return String(localized: "줄넘기")
case .boxing: return String(localized: "복싱")
case .dance: return String(localized: "댄스")
case .stretching: return String(localized: "스트레칭")
case .hiit: return String(localized: "고강도 인터벌(HIIT)")
case .other: return String(localized: "기타 운동")
}
}
var symbolName: String {
switch self {
case .running: return "figure.run"
case .walking: return "figure.walk"
case .cycling: return "figure.outdoor.cycle"
case .swimming: return "figure.pool.swim"
case .strength: return "dumbbell.fill"
case .yoga: return "figure.yoga"
case .pilates: return "figure.pilates"
case .hiking: return "figure.hiking"
case .soccer: return "figure.indoor.soccer"
case .baseball: return "figure.baseball"
case .basketball: return "figure.basketball"
case .tennis: return "figure.tennis"
case .badminton: return "figure.badminton"
case .golf: return "figure.golf"
case .jumpRope: return "figure.jumprope"
case .boxing: return "figure.boxing"
case .dance: return "figure.dance"
case .stretching: return "figure.flexibility"
case .hiit: return "figure.highintensity.intervaltraining"
case .other: return "figure.mixed.cardio"
}
}
}
// MARK: - (Quest.healthMetricRaw )
/// .
/// raw : HealthMetric raw , "workout.<kind>" ( CloudKit )
nonisolated enum HealthQuestTarget: Equatable {
case metric(HealthMetric)
case workout(HealthWorkoutKind)
init?(raw: String) {
guard !raw.isEmpty else { return nil }
if let metric = HealthMetric(rawValue: raw) {
self = .metric(metric)
} else if raw.hasPrefix("workout."),
let kind = HealthWorkoutKind(rawValue: String(raw.dropFirst("workout.".count))) {
self = .workout(kind)
} else {
// ' ' ( )
return nil
}
}
var raw: String {
switch self {
case .metric(let metric): return metric.rawValue
case .workout(let kind): return "workout.\(kind.rawValue)"
}
}
var name: String {
switch self {
case .metric(let metric): return metric.name
case .workout(let kind): return kind.name
}
}
var symbolName: String {
switch self {
case .metric(let metric): return metric.symbolName
case .workout(let kind): return kind.symbolName
}
}
/// ( ) · ( )
var isDuration: Bool {
switch self {
case .metric(let metric): return metric.isDuration
case .workout: return true
}
}
///
func valueLabel(_ value: Double) -> String {
switch self {
case .metric(let metric): return metric.valueLabel(value)
case .workout: return Format.durationShort(value)
}
}
}
// MARK: - × (App Group · )
nonisolated enum HealthCache {
static let storageKey = "health.cache.v1"
/// (§4.2 400)
static let retentionDays = 400
/// dayKey( Date) "yyyyMMdd"
static func dayToken(_ dayKey: Date) -> String {
let parts = Calendar.current.dateComponents([.year, .month, .day], from: dayKey)
let y = parts.year ?? 0, m = parts.month ?? 0, d = parts.day ?? 0
let mm = m < 10 ? "0\(m)" : "\(m)"
let dd = d < 10 ? "0\(d)" : "\(d)"
return "\(y)\(mm)\(dd)"
}
// MARK: (1.5(9) · )
//
// value() defaults.dictionary ( 10 × 400
// ) Swift . " ×
// " ~30, ~38+,
// ~76 1.5 .
// , ( ) defaults
// ( ) ( double 1 )
// .
private static let generationKey = "health.cache.gen"
private static let cacheLock = NSLock()
nonisolated(unsafe) private static var cached: [String: [String: Double]]?
nonisolated(unsafe) private static var cachedGeneration: Double = -1
private static func load() -> [String: [String: Double]] {
let generation = AppGroup.defaults.double(forKey: generationKey)
cacheLock.lock()
if let cached, cachedGeneration == generation {
let result = cached
cacheLock.unlock()
return result
}
cacheLock.unlock()
let fresh = (AppGroup.defaults.dictionary(forKey: storageKey) as? [String: [String: Double]]) ?? [:]
cacheLock.lock()
cached = fresh
cachedGeneration = generation
cacheLock.unlock()
return fresh
}
/// + + ( 3 ).
///
private static func store(_ all: [String: [String: Double]]?) {
if let all {
AppGroup.defaults.set(all, forKey: storageKey)
} else {
AppGroup.defaults.removeObject(forKey: storageKey)
}
let generation = AppGroup.defaults.double(forKey: generationKey) + 1
AppGroup.defaults.set(generation, forKey: generationKey)
cacheLock.lock()
cached = all ?? [:]
cachedGeneration = generation
cacheLock.unlock()
}
static func value(_ metricRaw: String, dayKey: Date) -> Double? {
load()[metricRaw]?[dayToken(dayKey)]
}
/// (+ )
static func setValues(_ values: [String: Double], dayKey: Date) {
guard !values.isEmpty else { return }
var all = load()
let token = dayToken(dayKey)
for (metricRaw, value) in values {
var days = all[metricRaw] ?? [:]
days[token] = value
if days.count > retentionDays {
let sorted = days.keys.sorted(by: >)
for old in sorted.dropFirst(retentionDays) { days.removeValue(forKey: old) }
}
all[metricRaw] = days
}
store(all)
}
/// × ( [: [: ]])
static func merge(_ values: [String: [Date: Double]]) {
guard !values.isEmpty else { return }
var all = load()
for (metricRaw, days) in values {
var stored = all[metricRaw] ?? [:]
for (dayKey, value) in days {
stored[dayToken(dayKey)] = value
}
if stored.count > retentionDays {
let sorted = stored.keys.sorted(by: >)
for old in sorted.dropFirst(retentionDays) { stored.removeValue(forKey: old) }
}
all[metricRaw] = stored
}
store(all)
}
/// ( )
static func clearAll() {
store(nil)
HealthIntervals.clearAll()
}
}
// MARK: - (1.5 · )
/// '' HealthCache , .
/// : "sleep" "workout.<kind>", : dayToken [[ epoch, epoch], ...].
///
/// ( ' ' , ).
/// HealthCache defaults CloudKit (5.1.3)
nonisolated enum HealthIntervals {
static let storageKey = "health.intervals.v1"
static let retentionDays = HealthCache.retentionDays
static let sleepKey = "sleep"
static func workoutKey(_ kind: HealthWorkoutKind) -> String { "workout.\(kind.rawValue)" }
// HealthCache (1.5(9) , · ).
// 3~5, ×7
private static let generationKey = "health.intervals.gen"
private static let cacheLock = NSLock()
nonisolated(unsafe) private static var cached: [String: [String: [[Double]]]]?
nonisolated(unsafe) private static var cachedGeneration: Double = -1
private static func load() -> [String: [String: [[Double]]]] {
let generation = AppGroup.defaults.double(forKey: generationKey)
cacheLock.lock()
if let cached, cachedGeneration == generation {
let result = cached
cacheLock.unlock()
return result
}
cacheLock.unlock()
let fresh = (AppGroup.defaults.dictionary(forKey: storageKey) as? [String: [String: [[Double]]]]) ?? [:]
cacheLock.lock()
cached = fresh
cachedGeneration = generation
cacheLock.unlock()
return fresh
}
private static func store(_ all: [String: [String: [[Double]]]]?) {
if let all {
AppGroup.defaults.set(all, forKey: storageKey)
} else {
AppGroup.defaults.removeObject(forKey: storageKey)
}
let generation = AppGroup.defaults.double(forKey: generationKey) + 1
AppGroup.defaults.set(generation, forKey: generationKey)
cacheLock.lock()
cached = all ?? [:]
cachedGeneration = generation
cacheLock.unlock()
}
/// · ( )
static func intervals(_ key: String, dayKey: Date) -> [(start: Date, end: Date)] {
let stored = load()[key]?[HealthCache.dayToken(dayKey)] ?? []
return stored.compactMap { pair in
guard pair.count == 2, pair[1] > pair[0] else { return nil }
return (Date(timeIntervalSince1970: pair[0]), Date(timeIntervalSince1970: pair[1]))
}.sorted { $0.start < $1.start }
}
/// ( )
static func workoutKeys(dayKey: Date) -> [String] {
let token = HealthCache.dayToken(dayKey)
// load() 1 (1.5(9))
let all = load()
return all.keys.filter { $0.hasPrefix("workout.") && !(all[$0]?[token] ?? []).isEmpty }
}
/// × (· ).
/// values: [ : [dayKey: [[ epoch, epoch]]]] " "
static func merge(_ values: [String: [Date: [[Double]]]]) {
guard !values.isEmpty else { return }
var all = load()
for (key, days) in values {
var stored = all[key] ?? [:]
for (dayKey, list) in days {
let token = HealthCache.dayToken(dayKey)
if list.isEmpty {
stored.removeValue(forKey: token)
} else {
stored[token] = list
}
}
if stored.count > retentionDays {
let sorted = stored.keys.sorted(by: >)
for old in sorted.dropFirst(retentionDays) { stored.removeValue(forKey: old) }
}
all[key] = stored
}
store(all)
}
static func clearAll() {
store(nil)
}
}
// MARK: - (§8 · )
nonisolated enum HealthSupport {
static let deviceAvailableKey = "health.deviceAvailable"
/// (=false).
/// ( ) true.
static var deviceAvailable: Bool {
guard AppGroup.defaults.object(forKey: deviceAvailableKey) != nil else { return true }
return AppGroup.defaults.bool(forKey: deviceAvailableKey)
}
}
// MARK: - (QuestProgress )
nonisolated enum HealthQuestValues {
#if DEBUG
/// (-progressSelfTest H ) ·
nonisolated(unsafe) static var testOverride: [String: [String: Double]]?
#endif
///
static func cacheKey(target: HealthQuestTarget, quest: Quest) -> String {
if case .metric(.sleep) = target {
return "sleep@\(quest.sleepWindowStartMinutes)-\(quest.sleepWindowEndMinutes)"
}
return target.raw
}
static func dayValue(target: HealthQuestTarget, quest: Quest, dayKey: Date) -> Double? {
let key = cacheKey(target: target, quest: quest)
#if DEBUG
if let override = testOverride {
return override[key]?[HealthCache.dayToken(dayKey)]
}
#endif
return HealthCache.value(key, dayKey: dayKey)
}
/// range .
/// range ( )
/// " = " now .
static func sum(target: HealthQuestTarget, quest: Quest, in range: Range<Date>, math: DayMath) -> Double {
math.dayKeys(in: range).reduce(0) { partial, key in
partial + (dayValue(target: target, quest: quest, dayKey: key) ?? 0)
}
}
}