Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,26 @@ import SwiftUI

struct PhotoFramePreview: View {
let information: PhotoInformation
private let hasPreloadedPhotoImages: Bool
@State private var photoImagesByURL: [URL: UIImage]
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PhotoCompositionView에서 PhotoFramePreview가 만들어지는 형태를 보면, 사진이 선택될 때마다 (상태가 변할 때마다) 새로운 PhotoFramePreview가 만들어지고 있습니다.
그래서 시도하신 캐싱 로직은 수행되지 않는 상태인 듯 합니다...!
해당 흐름 다시 한 번 확인부탁드립니다!

PhotoFramePreview(
    information: PhotoInformation(
        layout: store.state.selectedLayout,
        frame: store.state.selectedFrame,
        photos: store.state.selectedPhotos
    )
)

더해서 캐싱을 적용한다면, 이미지를 렌더링하는 CPU 사용률은 줄어들겠지만 UIImage 저장으로 인해 메모리 사용량은 늘어날 듯 한데요,
개선 이전에도, 개선 후에도 사용 측면에서는 버벅임이 없지만 이런 사용량 변화에 대한 분석도 같이 공유해주시면 좋을 듯 합니다.

Copy link
Copy Markdown
Collaborator Author

@opficdev opficdev Mar 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@sangYuLv

피드백 감사합니다!

  1. PhotoFramePreview

아시겠지만 SwiftUI는 상태가 변경되면 그 뷰를 완전히 새로 그리지 않고 재계산을 통해 알아서 뷰를 재사용하는 형태입니다. 특히 PhotoFramePreview에서 자주 변경이 되는 selectedPhotos 가 채택한 Photo 타입은 Identifiable을 채택함으로서 뷰 계산에 영향을 주고 있어서 selectedPhotos가 변경된다고 PhotoFramePreview가 완전히 다시 그려진다고 보기엔 좀 어려울것 같습니다. 아래 영상은 selectedPhotos가 변경되는 것과 별개로 PhotoFramePreview의 onAppear가 최초 한번만 뜨는 것을 확인할 수 있는 영상입니다

2026-03-13.3.54.12.mov
  1. 메모리 사용 및 캐싱

UIImage를 저장하는 딕셔너리(photoImagesByURL) 가 실제로 PhotoFramePreview에서의 메모리 캐싱 역할을 하고 있습니다. PhotoFramePreview를 설명한 대로, 뷰가 재계산되는 것이니 캐싱도 정상적으로 되는것을 확인할 수 있습니다. (아래 이미지 참고)

image

다만 변경 전과 변경 후에 대해 명확하게 설명하지 않는 것은 PR 영상과 기존 리팩토링 문서 아래쪽의 첫번째 영상과 비교가 가능하다고 생각하여 작성하지 않았는데 작성하는게 오히려 나았을 것 같네요. 요약 정리만 하자면, 개선 전은 최대 410MB, 개선 후는 최대 390MB 사용량을 보여 메모리 사용 개선이 조금 있었다고 말씀드릴 수 있겠습니다!


init(information: PhotoInformation, photoImages: [UIImage?] = []) {
self.information = information
self.hasPreloadedPhotoImages = photoImages.isEmpty == false
_photoImagesByURL = State(
initialValue: Dictionary(
uniqueKeysWithValues: zip(information.photos, photoImages).compactMap { photo, photoImage in
guard let photoImage else { return nil }
return (photo.url, photoImage)
}
)
)
}

var body: some View {
GeometryReader { geometry in
let size = geometry.size
let currentPhotoImages = information.photos.map { photoImagesByURL[$0.url] }

Canvas { context, _ in
let canvas = CGRect(origin: .zero, size: size)
Expand All @@ -29,9 +45,8 @@ struct PhotoFramePreview: View {
context.drawLayer { layer in
layer.clip(to: Path(roundedRect: slot, cornerRadius: 5))

if index < information.photos.count,
let photoData = information.photos[index].imageData,
let photo = UIImage(data: photoData) {
if index < currentPhotoImages.count,
let photo = currentPhotoImages[index] {
let target = aspectFillRect(for: photo.size, into: slot)
layer.draw(Image(uiImage: photo), in: target)
} else {
Expand Down Expand Up @@ -68,6 +83,21 @@ struct PhotoFramePreview: View {
}
}
.aspectRatio(information.layout.previewAspect, contentMode: .fit)
.task(id: information.photos) {
guard !hasPreloadedPhotoImages else { return }
await synchronizePhotoImages()
}
}

private func synchronizePhotoImages() async {
let currentPhotos = information.photos
for photo in currentPhotos where photoImagesByURL[photo.url] == nil {
let loadedPhotoImage = await PhotoImageLoader.loadImage(from: photo.url)
guard Task.isCancelled == false else { return }
guard let loadedPhotoImage else { continue }

photoImagesByURL[photo.url] = loadedPhotoImage
}
}

/// Cliping 될 때 크기에 맞게 잘 잘리도록 전처리
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,6 @@ struct Photo: Identifiable, Hashable {
let url: URL
var selectNumber: Int?

var imageData: Data? {
return try? Data(contentsOf: url)
}

static func == (lhs: Photo, rhs: Photo) -> Bool {
lhs.id == rhs.id
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,25 @@
import SwiftUI

struct PhotoComposer {
static func render(with information: PhotoInformation) async -> UIImage? {
let photoImages = await PhotoImageLoader.loadImages(from: information.photos)
// CI의 Xcode 16.4는 Default Actor Isolation을 `nonisolated`이 기본 설정
// Xcode 버전이 26.* 이라면 Default Actor Isolation을 `MainActor`이 기본 설정
// CI와 로컬 환경의 버전이 달라 임시로 CI 환경에 맞는 형태로 코드를 작성하였음
return await renderPreview(with: information, photoImages: photoImages)
}

@MainActor
static func render(with information: PhotoInformation) -> UIImage? {
private static func renderPreview(with information: PhotoInformation, photoImages: [UIImage?]) -> UIImage? {
// 출력 이미지 크기 결정 (예: 1080 x 1440 또는 레이아웃 비율에 맞춤)
let targetWidth: CGFloat = 1080
let targetHeight: CGFloat = targetWidth / information.layout.previewAspect
let targetSize = CGSize(width: targetWidth, height: targetHeight)

let preview = PhotoFramePreview(information: information)
let preview = PhotoFramePreview(
information: information,
photoImages: photoImages
)
.frame(width: targetSize.width, height: targetSize.height)

let renderer = ImageRenderer(content: preview)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
//
// PhotoImageLoader.swift
// mirroringBooth
//
// Created by 최윤진 on 2026-03-09.
//

import UIKit

enum PhotoImageLoader {
static func loadImages(from photos: [Photo]) async -> [UIImage?] {
var loadedPhotoImages = [UIImage?](repeating: nil, count: photos.count)

await withTaskGroup(of: (Int, UIImage?).self) { taskGroup in
for (index, photo) in photos.enumerated() {
taskGroup.addTask {
let loadedPhotoImage = await loadImage(from: photo.url)
return (index, loadedPhotoImage)
}
}

for await (index, loadedPhotoImage) in taskGroup {
loadedPhotoImages[index] = loadedPhotoImage
}
}

return loadedPhotoImages
}

static func loadImage(from photoUrl: URL) async -> UIImage? {
await Task.detached(priority: .userInitiated) {
UIImage(contentsOfFile: photoUrl.path(percentEncoded: false))
}.value
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ struct ResultView: View {
}
.task {
if let photoInformation = store.state.resultPhoto {
guard let image = PhotoComposer.render(with: photoInformation) else { return }
guard let image = await PhotoComposer.render(with: photoInformation) else { return }
store.send(.setRenderedImage(image: image))
}
}
Expand Down