perf(diary): 펜슬 저장 디바운스 — 획마다 전체 직렬화·블랍 재기록하던 버벅임 해소

- 획 종료 시 최신 PKDrawing만 보관, 1.2초 조용하면 백그라운드 직렬화 후 1회 저장 (세대 가드로 역전 방지)
- 플러시: 비활성 전환(페이지 이탈·배치 모드·그리기 끔)·dismantleUIView·willResignActive — 유실 창 최대 1.2초
- CloudKit 트래픽도 획당 1회→묶음 1회로 감소
- -diaryStrokeTest 검증 인자: 프로그램 획 → 배달 확인(strokes=3, delivered 정상) + 재실행 복원 스크린샷 확인

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NNxA172hNDRpDsJ2qztKPf
This commit is contained in:
songyc macbook 2026-08-11 16:36:24 +09:00
parent 5b526fa650
commit 6c3b918311

View File

@ -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<Void, Never>?
///
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()
}