From 6c3b91831124dfc692a10aaefe9f750f839550fa Mon Sep 17 00:00:00 2001 From: songyc macbook Date: Tue, 11 Aug 2026 16:36:24 +0900 Subject: [PATCH] =?UTF-8?q?perf(diary):=20=ED=8E=9C=EC=8A=AC=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=20=EB=94=94=EB=B0=94=EC=9A=B4=EC=8A=A4=20=E2=80=94=20?= =?UTF-8?q?=ED=9A=8D=EB=A7=88=EB=8B=A4=20=EC=A0=84=EC=B2=B4=20=EC=A7=81?= =?UTF-8?q?=EB=A0=AC=ED=99=94=C2=B7=EB=B8=94=EB=9E=8D=20=EC=9E=AC=EA=B8=B0?= =?UTF-8?q?=EB=A1=9D=ED=95=98=EB=8D=98=20=EB=B2=84=EB=B2=85=EC=9E=84=20?= =?UTF-8?q?=ED=95=B4=EC=86=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 획 종료 시 최신 PKDrawing만 보관, 1.2초 조용하면 백그라운드 직렬화 후 1회 저장 (세대 가드로 역전 방지) - 플러시: 비활성 전환(페이지 이탈·배치 모드·그리기 끔)·dismantleUIView·willResignActive — 유실 창 최대 1.2초 - CloudKit 트래픽도 획당 1회→묶음 1회로 감소 - -diaryStrokeTest 검증 인자: 프로그램 획 → 배달 확인(strokes=3, delivered 정상) + 재실행 복원 스크린샷 확인 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01NNxA172hNDRpDsJ2qztKPf --- myApp/HaruDanim/IOS/Views/DiaryNotePage.swift | 121 +++++++++++++++++- 1 file changed, 118 insertions(+), 3 deletions(-) diff --git a/myApp/HaruDanim/IOS/Views/DiaryNotePage.swift b/myApp/HaruDanim/IOS/Views/DiaryNotePage.swift index 0355757..3eee645 100644 --- a/myApp/HaruDanim/IOS/Views/DiaryNotePage.swift +++ b/myApp/HaruDanim/IOS/Views/DiaryNotePage.swift @@ -1286,6 +1286,41 @@ struct DiaryPencilCanvas: UIViewRepresentable { canvas.drawing = drawing } context.coordinator.canvas = canvas + #if DEBUG + // 검증용(-diaryStrokeTest): 3초 뒤 빨간 대각선 획을 추가하고 델리게이트 경로로 + // 디바운스 파이프라인을 태운 뒤, 6.5초 시점 상태를 Documents/diary-stroke-test.txt로 + // 남긴다 — strokes(획 수)와 delivered(실제 저장 배달 횟수)로 판정 (§14) + if isActive, UserDefaults.standard.bool(forKey: "diaryStrokeTest") { + let coordinator = context.coordinator + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak canvas] in + guard let canvas else { return } + var drawing = canvas.drawing + let points = stride(from: 0.0, through: 1.0, by: 0.05).map { t in + PKStrokePoint( + location: CGPoint(x: 80 + 600 * t, y: 150 + 700 * t), + timeOffset: t, size: CGSize(width: 6, height: 6), + opacity: 1, force: 1, azimuth: 0, altitude: .pi / 2 + ) + } + drawing.strokes.append(PKStroke( + ink: PKInk(.pen, color: .systemRed), + path: PKStrokePath(controlPoints: points, creationDate: .now) + )) + canvas.drawing = drawing + coordinator.canvasViewDrawingDidChange(canvas) + } + DispatchQueue.main.asyncAfter(deadline: .now() + 6.5) { [weak canvas] in + guard let canvas else { return } + let lines = [ + "strokes=\(canvas.drawing.strokes.count)", + "delivered=\(coordinator.debugDeliveredCount)", + ] + let url = URL.documentsDirectory.appending(path: "diary-stroke-test.txt") + try? lines.joined(separator: "\n") + .write(to: url, atomically: true, encoding: .utf8) + } + } + #endif return canvas } @@ -1299,6 +1334,11 @@ struct DiaryPencilCanvas: UIViewRepresentable { context.coordinator.setActive(isActive) } + static func dismantleUIView(_ uiView: SharpPencilCanvasView, coordinator: Coordinator) { + // 화면에서 떨어져 나갈 때(일기 상세 닫힘 등) 디바운스 창의 미저장 획을 저장 + coordinator.flush() + } + @MainActor final class Coordinator: NSObject, PKCanvasViewDelegate { let onChange: (Data) -> Void @@ -1306,8 +1346,78 @@ struct DiaryPencilCanvas: UIViewRepresentable { private let toolPicker = PKToolPicker() private var observing = false + // 저장 디바운스(1.4 펜슬 최적화): 예전에는 획을 뗄 때마다 전체 드로잉을 + // 직렬화(dataRepresentation)해 externalStorage 블랍을 통째로 재기록했다 — + // 획이 쌓일수록 비용이 선형 증가해 연속 필기가 버벅였다(양식 페이지의 큰 + // 래스터와 겹치면 더 체감). 이제 획 종료 시엔 최신 PKDrawing(값 타입)만 + // 보관하고, 1.2초 조용하면 백그라운드에서 직렬화해 1회 저장한다. + // 유실 방지 플러시 지점: 비활성 전환(페이지 이탈·배치 모드·그리기 끔)· + // 뷰 해체(dismantleUIView)·앱 비활성(willResignActive). + private var pendingDrawing: PKDrawing? + private var saveTask: Task? + /// 배달 세대 — 비행 중인 옛 직렬화 결과가 더 새 결과를 덮지 않게 한다 + private var deliverGeneration = 0 + private var resignObserver: NSObjectProtocol? + #if DEBUG + /// 검증용(-diaryStrokeTest): onChange(실제 저장)가 몇 번 배달됐는지 + var debugDeliveredCount = 0 + #endif + init(onChange: @escaping (Data) -> Void) { self.onChange = onChange + super.init() + resignObserver = NotificationCenter.default.addObserver( + forName: UIApplication.willResignActiveNotification, + object: nil, queue: .main + ) { [weak self] _ in + Task { @MainActor in self?.flush() } + } + } + + deinit { + if let resignObserver { + NotificationCenter.default.removeObserver(resignObserver) + } + } + + /// 디바운스 창이 열려 있으면 지금 즉시 직렬화·저장 (페이지 이탈 직전 등) + func flush() { + saveTask?.cancel() + saveTask = nil + guard let drawing = pendingDrawing else { return } + pendingDrawing = nil + deliverGeneration += 1 + #if DEBUG + debugDeliveredCount += 1 + #endif + onChange(drawing.dataRepresentation()) + } + + private func scheduleSave() { + saveTask?.cancel() + saveTask = Task { @MainActor [weak self] in + try? await Task.sleep(for: .seconds(1.2)) + guard !Task.isCancelled else { return } + self?.deliverPending() + } + } + + private func deliverPending() { + guard let drawing = pendingDrawing else { return } + pendingDrawing = nil + deliverGeneration += 1 + let generation = deliverGeneration + Task { @MainActor [weak self] in + // 직렬화는 페이지가 커질수록 비싸다 — 메인 밖에서 수행 + let data = await Task.detached(priority: .userInitiated) { + drawing.dataRepresentation() + }.value + guard let self, generation == self.deliverGeneration else { return } + #if DEBUG + self.debugDeliveredCount += 1 + #endif + self.onChange(data) + } } /// 양식 페이지에서는 팔레트가 고른 색도 라이트 기준으로 해석·저장되게 한다 — @@ -1334,13 +1444,18 @@ struct DiaryPencilCanvas: UIViewRepresentable { canvas.becomeFirstResponder() } } - } else if canvas.isFirstResponder { - canvas.resignFirstResponder() + } else { + // 비활성 전환(페이지 이탈·배치 모드·그리기 끔) — 미저장 획 즉시 저장 + flush() + if canvas.isFirstResponder { + canvas.resignFirstResponder() + } } } func canvasViewDrawingDidChange(_ canvasView: PKCanvasView) { - onChange(canvasView.drawing.dataRepresentation()) + pendingDrawing = canvasView.drawing + scheduleSave() // 새 획의 렌더 레이어가 기본 해상도로 생겼을 수 있어 다시 맞춘다 (canvasView as? SharpPencilCanvasView)?.applySharpness() }