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
This commit is contained in:
parent
5ce9dfdd6d
commit
2ae469d187
File diff suppressed because one or more lines are too long
@ -191,3 +191,11 @@
|
||||
- 시드: -seedHealthQuest 걸음 다짐에 #3E7CB1 시드(신규·멱등 재실행 모두 — 렌더 검증+스키마 내보내기 트리거 겸용). 도움말·whats-new 3언어 갱신, l10n 0/0.
|
||||
|
||||
- 후속(빌드 8): 위젯·워치 스냅숏이 targetColor 미경유(자체 폴백 — 위젯 분홍 하드코딩, 워치는 초록 폴백[1.5 잠복 표시 버그]) → healthColorHex 우선·기본 #E0558C로 통일. ④ 그리드 실측(걸음 파랑/기본 분홍). 스키마 무변경 — 콘솔 작업 불필요.
|
||||
|
||||
### §12 후속 4 — 성능 최적화 (2026-08-26, 빌드 9)
|
||||
|
||||
- **발단(사용자 소킹 보고)**: 1.5가 1.4 대비 전반적 버벅임 증가(아이폰 15PM·아이패드 3세대 공통, 기능 이상 없음). 원칙: **동작·수치·안정성 완전 불변 — 등가 최적화만** 적용(관찰 가능한 동작이 변하는 항목은 제외).
|
||||
- **진단(코드 전수 정독)**: 몸통은 건강 캐시 읽기 방식 — `HealthCache.value()`가 호출마다 전체 딕셔너리(지표 10여 종×400일 ≈ 수천 엔트리)를 defaults에서 통째로 브리징. 건강 다짐 진행률이 "하루 값×일수" 구조라 다짐 행 하나 렌더에 ~38회+, 워치 스냅숏(커밋마다·메인 스레드)에 다짐당 ~76회, 여기에 ②수면 백필 O(400일×전체 수면 샘플)의 하루 1회 메인 정지 ③위젯 라이브 타임라인의 엔트리 7개×새 컨테이너 오픈 ④DayMath.calendar 접근마다 Calendar 사본 생성(전역 곱셈) ⑤spanValue의 O(일수×기록수)(1.4부터 있던 것 — 데이터 증가로 악화)가 겹친 그림.
|
||||
- **적용 7건**: ⓐ건강 캐시 2종 인메모리화(세대 카운터로 프로세스 간 무효화, workoutKeys 이중 load 제거) ⓑDayMath.calendar 저장 프로퍼티화 ⓒ수면 백필 정렬+포인터 스위프 ⓓrefreshRecent 기록 배칭 ⓔspanValue/spanRawValue 하루 버킷화(**게이지의 '미래 기록 즉시 반영' 스펙 보존** — streak의 perDayValues와 별도 헬퍼 perDayGaugeValues/perDayRecordValues) ⓕ위젯 4프로바이더 makeEntry refresh 파라미터(타임라인당 컨테이너 1회) ⓖ일기 건강 카드 칩 value() 1회화.
|
||||
- **의도적 제외**: 건강 재조회 스로틀(신선도 변화=동작 변화), 워치 스냅숏 백그라운드화(SwiftData 메인 컨텍스트 위험), 모음 탭 셀 오늘 값 버킷화(구조 변경 — 소킹 체감 보고 후 재논의).
|
||||
- **검증(엄밀 A/B 포함)**: ①옛/새 빌드를 각각 **새 컨테이너+동일 시드**(seedDemo+건강 캐시+건강 다짐+주기 몫)로 캡처 — streak·건강 다짐·인텐트 스모크 덤프 3종 **값 완전 동일**(순서 무관 정렬 diff 0 — fetch 순서 비결정성만 존재), 목표 탭·모음 탭·위젯 ④·일기 4화면 **픽셀 단위 동일**(자작 pixdiff — 상태바 시계·시뮬 iCloud 배너만 제외) ②자가 검증 62/20/27/10 ALL PASS(26.5 심+**18.5 심 62건 별도 ALL PASS**) ③수면 스위프는 무작위 퍼즈 **3,000회 등가 증명**(창 40/400/7개×길이 1분~24h×DST식 겹침 창×구간 0~2,000개×경계 정확 접촉·0길이·전체 덮개) ④벌크 1만 건 시드에서 목표·통계·모음 3탭 렌더 정상(수치 실측 일치) ⑤3종 빌드(Debug·Store·워치) 그린 ⑥신규 현지화 문자열 0(카탈로그 무변경). 스토어: whats-new-1.5 3언어에 성능 개선 1줄 추가.
|
||||
|
||||
@ -601,7 +601,7 @@
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = IOS/Haru_Danim.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 8;
|
||||
CURRENT_PROJECT_VERSION = 9;
|
||||
DEVELOPMENT_TEAM = 5GNS4Z2SYT;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@ -643,7 +643,7 @@
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = IOS/Haru_Danim.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 8;
|
||||
CURRENT_PROJECT_VERSION = 9;
|
||||
DEVELOPMENT_TEAM = 5GNS4Z2SYT;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@ -686,7 +686,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = "Haru_DanimWatch Watch App/Haru_DanimWatch Watch App.entitlements";
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 8;
|
||||
CURRENT_PROJECT_VERSION = 9;
|
||||
DEVELOPMENT_TEAM = 5GNS4Z2SYT;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@ -722,7 +722,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = "Haru_DanimWatch Watch App/Haru_DanimWatch Watch App.entitlements";
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 8;
|
||||
CURRENT_PROJECT_VERSION = 9;
|
||||
DEVELOPMENT_TEAM = 5GNS4Z2SYT;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@ -758,7 +758,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = Haru_DanimWatchWidgets/Haru_DanimWatchWidgetsExtension.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 8;
|
||||
CURRENT_PROJECT_VERSION = 9;
|
||||
DEVELOPMENT_TEAM = 5GNS4Z2SYT;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = Haru_DanimWatchWidgets/Info.plist;
|
||||
@ -794,7 +794,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = Haru_DanimWatchWidgets/Haru_DanimWatchWidgetsExtension.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 8;
|
||||
CURRENT_PROJECT_VERSION = 9;
|
||||
DEVELOPMENT_TEAM = 5GNS4Z2SYT;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = Haru_DanimWatchWidgets/Info.plist;
|
||||
@ -827,7 +827,7 @@
|
||||
buildSettings = {
|
||||
CODE_SIGN_ENTITLEMENTS = Widgets/Haru_DanimWidgets.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 8;
|
||||
CURRENT_PROJECT_VERSION = 9;
|
||||
DEVELOPMENT_TEAM = 5GNS4Z2SYT;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = Widgets/Info.plist;
|
||||
@ -853,7 +853,7 @@
|
||||
buildSettings = {
|
||||
CODE_SIGN_ENTITLEMENTS = Widgets/Haru_DanimWidgets.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 8;
|
||||
CURRENT_PROJECT_VERSION = 9;
|
||||
DEVELOPMENT_TEAM = 5GNS4Z2SYT;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = Widgets/Info.plist;
|
||||
|
||||
@ -147,20 +147,32 @@ final class HealthDataStore {
|
||||
let cal = math.calendar
|
||||
let todayKey = math.dayKey(for: now)
|
||||
let keys = questHealthKeys()
|
||||
// 날짜별로 즉시 쓰지 않고 모아서 저장 1회씩 — 캐시 전체 기록(defaults 직렬화)이
|
||||
// 하루당 2회 × 일수에서 총 2회로 줄어든다. 최종 캐시 상태는 완전히 동일 (1.5(9) 최적화)
|
||||
var cacheBatch: [String: [Date: Double]] = [:]
|
||||
var intervalBatch: [String: [Date: [[Double]]]] = [:]
|
||||
for offset in 0..<max(1, days) {
|
||||
guard let key = cal.date(byAdding: .day, value: -offset, to: todayKey) else { continue }
|
||||
let values = await allValues(
|
||||
forDayKey: key, math: math,
|
||||
sleepWindows: keys.sleepWindows, workoutKinds: keys.workoutKinds
|
||||
)
|
||||
HealthCache.setValues(values, dayKey: key)
|
||||
HealthIntervals.merge(await dayIntervals(forDayKey: key, math: math))
|
||||
for (metricRaw, value) in values {
|
||||
cacheBatch[metricRaw, default: [:]][key] = value
|
||||
}
|
||||
for (intervalKey, dayLists) in await dayIntervals(forDayKey: key, math: math) {
|
||||
// 같은 종류 키가 여러 날짜에 걸쳐 나와도 안쪽 딕셔너리 키(dayKey)는 날짜별로
|
||||
// 유일하므로 단순 병합으로 충돌이 없다
|
||||
intervalBatch[intervalKey, default: [:]].merge(dayLists) { _, new in new }
|
||||
}
|
||||
if offset == 0 {
|
||||
var tiles: [HealthMetric: Double] = [:]
|
||||
for metric in HealthMetric.allCases { tiles[metric] = values[metric.rawValue] ?? 0 }
|
||||
todayValues = tiles
|
||||
}
|
||||
}
|
||||
HealthCache.merge(cacheBatch)
|
||||
HealthIntervals.merge(intervalBatch)
|
||||
}
|
||||
|
||||
/// 건강 다짐이 실제로 쓰는 캐시 키 집합 — 수면 구간 변형(기본 구간 포함)·운동 종목.
|
||||
@ -480,27 +492,42 @@ final class HealthDataStore {
|
||||
}
|
||||
merged[HealthMetric.mindfulMinutes.rawValue] = mindfulDays
|
||||
|
||||
// ④ 수면 — 구간 변형별로 각 날짜 창의 합집합 (창이 전날 밤으로 넘어가므로 하루 여유)
|
||||
// ④ 수면 — 구간 변형별로 각 날짜 창의 합집합 (창이 전날 밤으로 넘어가므로 하루 여유).
|
||||
// 1.5(9) 최적화: 예전에는 날짜(400)마다 전체 수면 샘플(수면 단계 로우데이터라
|
||||
// 수만 개)을 다시 순회해 O(일수×샘플수) — 하루 첫 실행의 메인 스레드 정지 원인.
|
||||
// 시작 시각으로 1회 정렬 후 창을 시간순으로 훑는 포인터 스위프로 바꿨다.
|
||||
// 날짜 창은 하루씩 뒤로 진행하므로 lower·upper가 모두 단조 증가 — ①start ≥ 창끝인
|
||||
// 구간은 이후로도 아직 안 왔고(hi), ②end ≤ 창시작인 구간은 이후 창에도 절대 못
|
||||
// 겹치므로(lo) 후보 창구간만 검사한다. 후보 중 실제 안 겹치는 것은 기존과 같은
|
||||
// ce > cs 판정이 거르므로 결과 수치는 예전 전수 검사와 완전히 동일하다.
|
||||
let sleepSamples = await categorySamples(
|
||||
.sleepAnalysis, in: lower.addingTimeInterval(-24 * 3600)..<upper
|
||||
)
|
||||
let asleepValues = Set(HKCategoryValueSleepAnalysis.allAsleepValues.map(\.rawValue))
|
||||
let asleepIntervals: [(Date, Date)] = sleepSamples.compactMap { sample in
|
||||
asleepValues.contains(sample.value) ? (sample.startDate, sample.endDate) : nil
|
||||
}
|
||||
let asleepIntervals: [(Date, Date)] = sleepSamples
|
||||
.compactMap { sample in
|
||||
asleepValues.contains(sample.value) ? (sample.startDate, sample.endDate) : nil
|
||||
}
|
||||
.sorted { $0.0 < $1.0 }
|
||||
for spec in keys.sleepWindows {
|
||||
guard let parsed = Self.windowSpec(spec) else { continue }
|
||||
var days: [Date: Double] = [:]
|
||||
var key = startKey
|
||||
var lo = 0
|
||||
var hi = 0
|
||||
while key <= todayKey {
|
||||
let window = Self.sleepWindow(
|
||||
forDayRange: math.dayRange(forKey: key),
|
||||
endMinutes: parsed.endMinutes, length: parsed.length
|
||||
)
|
||||
let clipped: [(Date, Date)] = asleepIntervals.compactMap { start, end in
|
||||
while hi < asleepIntervals.count, asleepIntervals[hi].0 < window.upperBound { hi += 1 }
|
||||
while lo < hi, asleepIntervals[lo].1 <= window.lowerBound { lo += 1 }
|
||||
var clipped: [(Date, Date)] = []
|
||||
for index in lo..<hi {
|
||||
let (start, end) = asleepIntervals[index]
|
||||
let cs = max(start, window.lowerBound)
|
||||
let ce = min(end, window.upperBound)
|
||||
return ce > cs ? (cs, ce) : nil
|
||||
if ce > cs { clipped.append((cs, ce)) }
|
||||
}
|
||||
let value = Self.mergedDuration(clipped)
|
||||
if value > 0 { days[key] = value }
|
||||
|
||||
@ -499,7 +499,9 @@ struct DiaryHealthCard: View {
|
||||
}
|
||||
|
||||
private func chip(_ metric: HealthMetric) -> some View {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
// 캐시 조회는 칩당 1회만 — 예전엔 본문·접근성 라벨이 각각 value()를 불렀다 (1.5(9))
|
||||
let value = value(metric)
|
||||
return VStack(alignment: .leading, spacing: 3) {
|
||||
HStack(spacing: 5) {
|
||||
Image(safeSymbol: metric.symbolName)
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
@ -509,7 +511,7 @@ struct DiaryHealthCard: View {
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
if let value = value(metric) {
|
||||
if let value {
|
||||
// ja "7時間28分"처럼 긴 값이 칩 폭에서 말줄임되지 않게 살짝 축소 허용 (1.5(4) 실측)
|
||||
Text(metric.valueLabel(value))
|
||||
.font(.system(.subheadline, design: .rounded).weight(.bold).monospacedDigit())
|
||||
@ -526,7 +528,7 @@ struct DiaryHealthCard: View {
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Color.primary.opacity(0.045), in: RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityLabel(Text(verbatim: "\(metric.name) \(value(metric).map(metric.valueLabel) ?? String(localized: "기록 없음"))"))
|
||||
.accessibilityLabel(Text(verbatim: "\(metric.name) \(value.map(metric.valueLabel) ?? String(localized: "기록 없음"))"))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -18,6 +18,7 @@
|
||||
• 측정 중엔 노란 타이머로 바뀌고, 누르면 워치 앱에서 바로 시작·종료할 수 있어요
|
||||
|
||||
● 그리고
|
||||
• 앱 곳곳의 반응 속도를 개선했어요 — 목표·다짐 화면과 위젯이 한결 가벼워져요
|
||||
• 도움말 전체 문구를 더 자연스럽게 다듬고 새 기능 사용법을 더했어요
|
||||
• 여러 기기를 쓰신다면 모든 기기에서 함께 업데이트해 주세요
|
||||
|
||||
@ -39,6 +40,7 @@ This update brings Haru Danim closer to Apple Health — and your Apple Watch. A
|
||||
• While tracking it turns into a yellow timer — tap to start or stop from the watch app
|
||||
|
||||
● And more
|
||||
• Performance improvements across the app — goals, quests, and widgets feel noticeably smoother
|
||||
• The entire in-app guide has been rewritten to read more naturally, with how-tos for the new features
|
||||
• Using several devices? Please update Haru Danim on all of them
|
||||
|
||||
@ -60,5 +62,6 @@ This update brings Haru Danim closer to Apple Health — and your Apple Watch. A
|
||||
• 計測中は黄色いタイマーに変わり、タップするとウォッチアプリからすぐ開始・終了できます
|
||||
|
||||
● そのほか
|
||||
• アプリ全体の反応速度を改善しました — 目標・クエスト画面やウィジェットがより軽快になります
|
||||
• アプリ内ヘルプの文章全体をより自然に磨き直し、新機能の使い方も加えました
|
||||
• 複数の端末をお使いなら、すべての端末で一緒にアップデートしてください
|
||||
|
||||
@ -10,13 +10,17 @@ import Foundation
|
||||
/// 논리적 하루(하루 시작 시간 반영), 주/월 범위, 세션 분할 계산
|
||||
struct DayMath {
|
||||
let settings: TrackingSettings
|
||||
/// 설정이 반영된 캘린더 — 저장 프로퍼티로 1회만 생성 (1.5(9) 최적화).
|
||||
/// 예전에는 계산 프로퍼티라 접근할 때마다 Calendar.current 사본 생성+firstWeekday 변경이
|
||||
/// 일어났는데, dayKey 한 번에 3회·dayKeys/splitByDay는 일수×수 회 접근이라 모든 집계에
|
||||
/// 곱해지는 숨은 비용이었다. settings가 불변이므로 결과는 완전히 동일하다.
|
||||
let calendar: Calendar
|
||||
|
||||
init(settings: TrackingSettings = .current()) {
|
||||
self.settings = settings
|
||||
self.calendar = settings.calendar
|
||||
}
|
||||
|
||||
var calendar: Calendar { settings.calendar }
|
||||
|
||||
// MARK: 논리적 하루
|
||||
|
||||
/// `date`가 속하는 논리적 하루의 키(해당 달력일의 자정 Date).
|
||||
|
||||
@ -221,8 +221,51 @@ nonisolated enum HealthCache {
|
||||
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]] {
|
||||
(AppGroup.defaults.dictionary(forKey: storageKey) as? [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? {
|
||||
@ -243,7 +286,7 @@ nonisolated enum HealthCache {
|
||||
}
|
||||
all[metricRaw] = days
|
||||
}
|
||||
AppGroup.defaults.set(all, forKey: storageKey)
|
||||
store(all)
|
||||
}
|
||||
|
||||
/// 여러 날짜 × 여러 지표 일괄 기록 (백필용 — [지표: [토큰: 값]])
|
||||
@ -261,12 +304,12 @@ nonisolated enum HealthCache {
|
||||
}
|
||||
all[metricRaw] = stored
|
||||
}
|
||||
AppGroup.defaults.set(all, forKey: storageKey)
|
||||
store(all)
|
||||
}
|
||||
|
||||
/// 전체 초기화(데이터 정리)용 — 값은 어차피 건강 앱 소유라 캐시만 비운다
|
||||
static func clearAll() {
|
||||
AppGroup.defaults.removeObject(forKey: storageKey)
|
||||
store(nil)
|
||||
HealthIntervals.clearAll()
|
||||
}
|
||||
}
|
||||
@ -285,8 +328,42 @@ nonisolated enum HealthIntervals {
|
||||
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]]]] {
|
||||
(AppGroup.defaults.dictionary(forKey: storageKey) as? [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()
|
||||
}
|
||||
|
||||
/// 한 종류·한 날짜의 구간 목록 (시작 오름차순)
|
||||
@ -301,7 +378,9 @@ nonisolated enum HealthIntervals {
|
||||
/// 그날 저장된 운동 종류 키 목록 (표시 순서는 호출부가 정함)
|
||||
static func workoutKeys(dayKey: Date) -> [String] {
|
||||
let token = HealthCache.dayToken(dayKey)
|
||||
return load().keys.filter { $0.hasPrefix("workout.") && !(load()[$0]?[token] ?? []).isEmpty }
|
||||
// load()는 1회만 — 예전에는 필터 클로저 안에서 키 개수만큼 다시 로드했다 (1.5(9))
|
||||
let all = load()
|
||||
return all.keys.filter { $0.hasPrefix("workout.") && !(all[$0]?[token] ?? []).isEmpty }
|
||||
}
|
||||
|
||||
/// 여러 날짜 × 여러 종류 일괄 기록 (백필·최근 갱신 공용).
|
||||
@ -325,11 +404,11 @@ nonisolated enum HealthIntervals {
|
||||
}
|
||||
all[key] = stored
|
||||
}
|
||||
AppGroup.defaults.set(all, forKey: storageKey)
|
||||
store(all)
|
||||
}
|
||||
|
||||
static func clearAll() {
|
||||
AppGroup.defaults.removeObject(forKey: storageKey)
|
||||
store(nil)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -263,16 +263,85 @@ struct QuestProgress {
|
||||
}
|
||||
let caps = quest.direction == .atLeast && quest.targetValue > 0
|
||||
let dailyTarget = quest.targetValue
|
||||
// 1.5(9) 최적화: 날마다 value(in:)로 전체 기록을 다시 훑던 O(일수×기록수) 루프를
|
||||
// 게이지 전용 하루 버킷(perDayGaugeValues — 기록 1회 순회, 클리핑 규칙 동일)으로 교체.
|
||||
// 수행일 필터·하루 기여 캡은 그대로 이 루프가 담당하므로 수치가 완전히 동일하다
|
||||
let daily = perDayGaugeValues(in: range, now: now)
|
||||
var sum = 0.0
|
||||
for key in math.dayKeys(in: range) where isActiveDay(key) {
|
||||
// 하루 값은 value(in:) 단일 지점 경유 — 기록 다짐은 기존과 동일한 Aggregator
|
||||
// 계산이고, 건강 다짐(1.5)은 캐시 하루 값이 그대로 나온다
|
||||
let dayValue = value(in: dayMeasurementRange(forKey: key), now: now)
|
||||
let dayValue = daily[key] ?? 0
|
||||
sum += caps ? min(dayValue, dailyTarget) : dayValue
|
||||
}
|
||||
return sum
|
||||
}
|
||||
|
||||
/// span 게이지용 하루 값 버킷 — "날마다 value(in: dayMeasurementRange)"와 수치가 완전히
|
||||
/// 동일하도록 같은 클리핑 규칙(구간 겹침 분할·마감 창·진행 중 세션은 now까지)을 기록을
|
||||
/// 한 번만 순회하며 적용한다 (1.5(9) — O(일수×기록수) → O(기록수+일수)).
|
||||
/// ⚠️ 연속 계산의 perDayValues와 헷갈리지 말 것: 그쪽은 종료 시각을 now로 잘라 미래
|
||||
/// 기록을 제외하지만(§6.4 연속 규칙), 게이지는 "미래 시각 기록도 즉시 반영"이 스펙이라
|
||||
/// 여기서는 자르지 않는다 — 기존 value(in:) 경로(Aggregator)와 동일한 동작.
|
||||
private func perDayGaugeValues(in range: Range<Date>, now: Date) -> [Date: Double] {
|
||||
// 건강 다짐: 캐시의 하루 값 그대로 (마감 시각은 건강 다짐 미지원 — 기존 경로도
|
||||
// dayMeasurementRange가 캐시의 온전한 하루 값을 돌려줬으므로 동일)
|
||||
if let health = quest.healthTarget {
|
||||
var values: [Date: Double] = [:]
|
||||
for key in math.dayKeys(in: range) {
|
||||
if let value = HealthQuestValues.dayValue(target: health, quest: quest, dayKey: key) {
|
||||
values[key] = value
|
||||
}
|
||||
}
|
||||
return values
|
||||
}
|
||||
return perDayRecordValues(in: range, now: now)
|
||||
}
|
||||
|
||||
/// 기록(행동·꼬리표) 다짐의 게이지용 하루 버킷 — perDayGaugeValues의 기록 경로.
|
||||
/// spanRawValue(마감 시각)가 직접 쓰는 이유: 예전 구현이 Aggregator만 탔으므로
|
||||
/// (건강 다짐은 targetActions가 비어 항상 0) 그 동작을 비트 단위로 보존하기 위함
|
||||
private func perDayRecordValues(in range: Range<Date>, now: Date) -> [Date: Double] {
|
||||
var values: [Date: Double] = [:]
|
||||
// 마감 창 끝은 날짜별 캐시 — perDayValues(연속 계산)와 같은 문법
|
||||
let clips = quest.hasDeadline
|
||||
var windowEnds: [Date: Date] = [:]
|
||||
func windowEnd(_ key: Date) -> Date {
|
||||
if let cached = windowEnds[key] { return cached }
|
||||
let end = dayMeasurementRange(forKey: key).upperBound
|
||||
windowEnds[key] = end
|
||||
return end
|
||||
}
|
||||
for action in quest.targetActions {
|
||||
switch quest.measure {
|
||||
case .time:
|
||||
for session in action.sessions {
|
||||
// 진행 중 세션은 now까지 — 종료 시각이 미래인 수동 기록은 그대로 반영
|
||||
// (Aggregator.seconds의 `session.endAt ?? now`와 동일)
|
||||
let end = session.endAt ?? now
|
||||
let start = max(session.startAt, range.lowerBound)
|
||||
let clippedEnd = min(end, range.upperBound)
|
||||
guard start < clippedEnd else { continue }
|
||||
for segment in math.splitByDay(start: start, end: clippedEnd) {
|
||||
var upper = segment.range.upperBound
|
||||
if clips {
|
||||
// 조각 시작은 항상 그날 창의 시작(하루 시작) 이후 — 끝만 자르면 된다
|
||||
upper = min(upper, windowEnd(segment.dayKey))
|
||||
guard segment.range.lowerBound < upper else { continue }
|
||||
}
|
||||
values[segment.dayKey, default: 0]
|
||||
+= upper.timeIntervalSince(segment.range.lowerBound)
|
||||
}
|
||||
}
|
||||
case .count:
|
||||
for entry in action.countEntries where range.contains(entry.timestamp) {
|
||||
let key = math.dayKey(for: entry.timestamp)
|
||||
if clips, entry.timestamp >= windowEnd(key) { continue }
|
||||
values[key, default: 0] += Double(entry.amount)
|
||||
}
|
||||
}
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
/// span 구간의 실측 누적값 — 위젯 ③ 누적값 라벨용.
|
||||
/// 진행률 캡(하루 기여 제한)·적용일 여부와 무관한 "원본" 성격의 값이지만,
|
||||
/// 마감 시각 다짐은 각 날의 집계 창(마감까지)만 센다 — 마감 뒤 기록으로
|
||||
@ -280,16 +349,10 @@ struct QuestProgress {
|
||||
func spanRawValue(_ span: StatSpan, now: Date = .now) -> Double {
|
||||
let range = spanRange(span, now: now)
|
||||
guard quest.hasDeadline else { return value(in: range, now: now) }
|
||||
let agg = Aggregator(math: math)
|
||||
var sum = 0.0
|
||||
for key in math.dayKeys(in: range) {
|
||||
let dayRange = dayMeasurementRange(forKey: key)
|
||||
switch quest.measure {
|
||||
case .time: sum += agg.seconds(for: quest.targetActions, in: dayRange, now: now)
|
||||
case .count: sum += Double(agg.count(for: quest.targetActions, in: dayRange))
|
||||
}
|
||||
}
|
||||
return sum
|
||||
// 1.5(9) 최적화: 날짜마다 Aggregator로 전체 기록을 재순회하던 것을 하루 버킷 1회로.
|
||||
// 예전 구현과 동일하게 기록 경로만 탄다 (perDayRecordValues 주석 참고)
|
||||
let daily = perDayRecordValues(in: range, now: now)
|
||||
return math.dayKeys(in: range).reduce(0) { $0 + (daily[$1] ?? 0) }
|
||||
}
|
||||
|
||||
// MARK: 주기 몫 완료 상태 (§4.2)
|
||||
|
||||
@ -92,8 +92,10 @@ struct GoalProgressProvider: AppIntentTimelineProvider {
|
||||
|
||||
@MainActor
|
||||
static func makeEntry(_ configuration: GoalProgressConfigIntent, family: WidgetFamily,
|
||||
now: Date = .now) -> GoalProgressEntry {
|
||||
IntentStore.refresh()
|
||||
now: Date = .now, refresh: Bool = true) -> GoalProgressEntry {
|
||||
// 미래 엔트리(라이브 타임라인)는 같은 시점의 디스크 상태를 봐야 하므로 컨테이너를
|
||||
// 다시 열지 않는다 — 예전엔 엔트리 7개가 각각 새 컨테이너를 열었다 (1.5(9) 최적화)
|
||||
if refresh { IntentStore.refresh() }
|
||||
guard WidgetStore.isUnlocked else {
|
||||
return GoalProgressEntry(date: now, locked: true, theme: configuration.theme,
|
||||
mediumStyle: configuration.mediumStyle,
|
||||
@ -125,7 +127,7 @@ struct GoalProgressProvider: AppIntentTimelineProvider {
|
||||
func timeline(for configuration: GoalProgressConfigIntent, in context: Context) async -> Timeline<GoalProgressEntry> {
|
||||
let first = Self.makeEntry(configuration, family: context.family)
|
||||
return WidgetRefresh.timeline(first: first, live: first.isLive) { date in
|
||||
Self.makeEntry(configuration, family: context.family, now: date)
|
||||
Self.makeEntry(configuration, family: context.family, now: date, refresh: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -458,8 +460,9 @@ struct QuestStatusProvider: AppIntentTimelineProvider {
|
||||
|
||||
@MainActor
|
||||
static func makeEntry(_ configuration: QuestStatusConfigIntent, family: WidgetFamily,
|
||||
now: Date = .now) -> QuestStatusEntry {
|
||||
IntentStore.refresh()
|
||||
now: Date = .now, refresh: Bool = true) -> QuestStatusEntry {
|
||||
// 미래 엔트리는 컨테이너 재오픈 없이 같은 상태에서 계산 (GoalProgressProvider와 동일)
|
||||
if refresh { IntentStore.refresh() }
|
||||
guard WidgetStore.isUnlocked else {
|
||||
return QuestStatusEntry(date: now, locked: true, theme: configuration.theme,
|
||||
spanLabel: "", largeStyle: configuration.largeStyle, goals: [])
|
||||
@ -492,7 +495,7 @@ struct QuestStatusProvider: AppIntentTimelineProvider {
|
||||
func timeline(for configuration: QuestStatusConfigIntent, in context: Context) async -> Timeline<QuestStatusEntry> {
|
||||
let first = Self.makeEntry(configuration, family: context.family)
|
||||
return WidgetRefresh.timeline(first: first, live: first.isLive) { date in
|
||||
Self.makeEntry(configuration, family: context.family, now: date)
|
||||
Self.makeEntry(configuration, family: context.family, now: date, refresh: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -60,8 +60,10 @@ struct LockGoalEntry: TimelineEntry {
|
||||
|
||||
struct LockGoalProvider: AppIntentTimelineProvider {
|
||||
@MainActor
|
||||
static func makeEntry(_ configuration: LockGoalConfigIntent, now: Date = .now) -> LockGoalEntry {
|
||||
IntentStore.refresh()
|
||||
static func makeEntry(_ configuration: LockGoalConfigIntent, now: Date = .now,
|
||||
refresh: Bool = true) -> LockGoalEntry {
|
||||
// 미래 엔트리는 컨테이너 재오픈 없이 같은 상태에서 계산 (GoalProgressProvider와 동일)
|
||||
if refresh { IntentStore.refresh() }
|
||||
guard PremiumGate.isUnlocked(.lockScreenWidgets) else {
|
||||
return LockGoalEntry(date: now, locked: true, spanLabel: "", style: .gauge, goal: nil, ratio: 0)
|
||||
}
|
||||
@ -92,7 +94,7 @@ struct LockGoalProvider: AppIntentTimelineProvider {
|
||||
func timeline(for configuration: LockGoalConfigIntent, in context: Context) async -> Timeline<LockGoalEntry> {
|
||||
let first = Self.makeEntry(configuration)
|
||||
return WidgetRefresh.timeline(first: first, live: first.isLive) { date in
|
||||
Self.makeEntry(configuration, now: date)
|
||||
Self.makeEntry(configuration, now: date, refresh: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -56,8 +56,9 @@ struct QuestRingProvider: AppIntentTimelineProvider {
|
||||
|
||||
@MainActor
|
||||
static func makeEntry(_ configuration: QuestRingConfigIntent, family: WidgetFamily,
|
||||
now: Date = .now) -> QuestRingEntry {
|
||||
IntentStore.refresh()
|
||||
now: Date = .now, refresh: Bool = true) -> QuestRingEntry {
|
||||
// 미래 엔트리는 컨테이너 재오픈 없이 같은 상태에서 계산 (GoalProgressProvider와 동일)
|
||||
if refresh { IntentStore.refresh() }
|
||||
guard WidgetStore.isUnlocked else {
|
||||
return QuestRingEntry(date: now, locked: true, theme: configuration.theme, spanLabel: "", cells: [])
|
||||
}
|
||||
@ -88,7 +89,7 @@ struct QuestRingProvider: AppIntentTimelineProvider {
|
||||
func timeline(for configuration: QuestRingConfigIntent, in context: Context) async -> Timeline<QuestRingEntry> {
|
||||
let first = Self.makeEntry(configuration, family: context.family)
|
||||
return WidgetRefresh.timeline(first: first, live: first.isLive) { date in
|
||||
Self.makeEntry(configuration, family: context.family, now: date)
|
||||
Self.makeEntry(configuration, family: context.family, now: date, refresh: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user