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() }