From 8c58c08a851d16373d0ce43b1f4b6bd6a6b4075f Mon Sep 17 00:00:00 2001 From: Yu Sung Date: Thu, 23 Oct 2025 15:31:52 +0900 Subject: [PATCH] =?UTF-8?q?perf(banner):=20TabView=20=ED=94=84=EB=A6=AC?= =?UTF-8?q?=EB=A1=9C=EB=94=A9=20=EC=99=84=ED=99=94=C2=B7=EB=8B=A4=EC=9A=B4?= =?UTF-8?q?=EC=83=98=ED=94=8C=EB=A7=81=C2=B7=EC=9A=94=EC=B2=AD=EC=B7=A8?= =?UTF-8?q?=EC=86=8C=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 배너/캐러셀에서 인접 페이지 프리로딩과 원본 해상도 디코딩으로 발생하던 메모리 스파이크와 중복 로드를 완화했습니다. - 각 페이지에서 이미지 URL을 onAppear에 바인딩, onDisappear에 nil 해제 → 인접 페이지 프리로딩 시 중복 로드·디코딩 방지, 요청 취소 실효 - 모든 KFImage에 cancelOnDisappear(true) 일관 적용 - 큰 배너 이미지에 downsampling(size:) 적용(디코딩 메모리 절감) - 자동 슬라이드 주기 3초 → 4초로 완화(동시 로드 빈도 감소) - TabView 페이지를 서브뷰로 분리하여 뷰 로직 단순화 및 재사용성 향상 결과: 동시 디코딩 감소, 피크 메모리 사용량 하락, 자동 슬라이드 안정성 개선 --- .../Banner/AutoSlideCharacterBannerView.swift | 60 +++++++-- .../Main/Banner/ContentMainBannerView.swift | 121 +++++++++--------- .../Banner/ContentMainBannerViewModel.swift | 2 +- .../Main/V2/ContentMainBannerViewV2.swift | 59 ++++++--- .../EventBanner/SectionEventBannerView.swift | 104 ++++++++------- 5 files changed, 203 insertions(+), 143 deletions(-) diff --git a/SodaLive/Sources/Chat/Character/Banner/AutoSlideCharacterBannerView.swift b/SodaLive/Sources/Chat/Character/Banner/AutoSlideCharacterBannerView.swift index 2995c22..6d61f6f 100644 --- a/SodaLive/Sources/Chat/Character/Banner/AutoSlideCharacterBannerView.swift +++ b/SodaLive/Sources/Chat/Character/Banner/AutoSlideCharacterBannerView.swift @@ -21,18 +21,13 @@ struct AutoSlideCharacterBannerView: View { TabView(selection: $currentIndex) { ForEach(0.. Void + @State private var boundURL: URL? + + var body: some View { + Group { + if let boundURL { + KFImage(boundURL) + .placeholder { Color.gray.opacity(0.2) } + .retry(maxCount: 2, interval: .seconds(1)) + .cancelOnDisappear(true) + .downsampling(size: CGSize(width: width, height: height)) + .resizable() + .scaledToFill() + .frame(width: width, height: height) + .clipped() + .cornerRadius(12) + } else { + Color.clear + .frame(width: width, height: height) + .cornerRadius(12) + } + } + .contentShape(Rectangle()) + .onTapGesture { onTap(item) } + .onAppear { + let encoded = item.imageUrl.addingPercentEncoding( + withAllowedCharacters: .urlQueryAllowed + ) ?? item.imageUrl + boundURL = URL(string: encoded) + } + .onDisappear { + boundURL = nil + } + } +} diff --git a/SodaLive/Sources/Content/Main/Banner/ContentMainBannerView.swift b/SodaLive/Sources/Content/Main/Banner/ContentMainBannerView.swift index e1e79c2..89b9052 100644 --- a/SodaLive/Sources/Content/Main/Banner/ContentMainBannerView.swift +++ b/SodaLive/Sources/Content/Main/Banner/ContentMainBannerView.swift @@ -19,67 +19,12 @@ struct ContentMainBannerView: View { TabView(selection: $viewModel.currentIndex) { ForEach(0.. 0, let url = URL(string: link), UIApplication.shared.canOpenURL(url) { - UIApplication.shared.open(url) - } - } - } - .cornerRadius(4.7) - } else { - KFImage(URL(string: item.thumbnailImageUrl)) - .cancelOnDisappear(true) - .downsampling( - size: CGSize( - width: screenSize().width - 26.7, - height: (screenSize().width - 26.7) * 0.53 - ) - ) - .resizable() - .scaledToFill() - .frame( - width: screenSize().width - 26.7, - height: (screenSize().width - 26.7) * 0.53 - ) - .onTapGesture { - switch item.type { - case .EVENT: - AppState.shared.setAppStep(step: .eventDetail(event: item.eventItem!)) - case .CREATOR: - AppState.shared.setAppStep(step: .creatorDetail(userId: item.creatorId!)) - case .SERIES: - AppState.shared.setAppStep(step: .seriesDetail(seriesId: item.seriesId!)) - case .LINK: - if let link = item.link, link.trimmingCharacters(in: .whitespaces).count > 0, let url = URL(string: link), UIApplication.shared.canOpenURL(url) { - UIApplication.shared.open(url) - } - } - } - .cornerRadius(4.7) - } + ContentMainBannerPage( + item: item, + width: screenSize().width - 26.7, + height: (screenSize().width - 26.7) * 0.53 + ) + .tag(index) } } .tabViewStyle(PageTabViewStyle(indexDisplayMode: .never)) @@ -103,7 +48,7 @@ struct ContentMainBannerView: View { } .frame(maxWidth: .infinity) .onAppear { - viewModel.timer = Timer.publish(every: 3, on: .main, in: .common).autoconnect() + viewModel.timer = Timer.publish(every: 4, on: .main, in: .common).autoconnect() } .onDisappear { viewModel.timer.upstream.connect().cancel() @@ -133,6 +78,58 @@ struct ContentMainBannerView: View { } } +private struct ContentMainBannerPage: View { + let item: GetAudioContentBannerResponse + let width: CGFloat + let height: CGFloat + @State private var boundURL: URL? + + var body: some View { + Group { + if let boundURL { + KFImage(boundURL) + .cancelOnDisappear(true) + .downsampling(size: CGSize(width: width, height: height)) + .resizable() + .scaledToFill() + .frame(width: width, height: height) + .cornerRadius(4.7) + } else { + Color.clear + .frame(width: width, height: height) + .cornerRadius(4.7) + } + } + .contentShape(Rectangle()) + .onTapGesture { + switch item.type { + case .EVENT: + AppState.shared.setAppStep(step: .eventDetail(event: item.eventItem!)) + case .CREATOR: + AppState.shared.setAppStep(step: .creatorDetail(userId: item.creatorId!)) + case .SERIES: + AppState.shared.setAppStep(step: .seriesDetail(seriesId: item.seriesId!)) + case .LINK: + if let link = item.link, + link.trimmingCharacters(in: .whitespaces).count > 0, + let url = URL(string: link), + UIApplication.shared.canOpenURL(url) { + UIApplication.shared.open(url) + } + } + } + .onAppear { + let urlString = item.thumbnailImageUrl.addingPercentEncoding( + withAllowedCharacters: .urlQueryAllowed + ) ?? item.thumbnailImageUrl + boundURL = URL(string: urlString) + } + .onDisappear { + boundURL = nil + } + } +} + struct ContentMainBannerView_Previews: PreviewProvider { static var previews: some View { ContentMainBannerView() diff --git a/SodaLive/Sources/Content/Main/Banner/ContentMainBannerViewModel.swift b/SodaLive/Sources/Content/Main/Banner/ContentMainBannerViewModel.swift index 650b50a..a1b2b99 100644 --- a/SodaLive/Sources/Content/Main/Banner/ContentMainBannerViewModel.swift +++ b/SodaLive/Sources/Content/Main/Banner/ContentMainBannerViewModel.swift @@ -18,7 +18,7 @@ final class ContentMainBannerViewModel: ObservableObject { @Published var isLoading = false @Published var currentIndex = 0 - @Published var timer = Timer.publish(every: 3, on: .main, in: .common).autoconnect() + @Published var timer = Timer.publish(every: 4, on: .main, in: .common).autoconnect() @Published var bannerList = [GetAudioContentBannerResponse]() diff --git a/SodaLive/Sources/Content/Main/V2/ContentMainBannerViewV2.swift b/SodaLive/Sources/Content/Main/V2/ContentMainBannerViewV2.swift index 3ff828b..13fabe9 100644 --- a/SodaLive/Sources/Content/Main/V2/ContentMainBannerViewV2.swift +++ b/SodaLive/Sources/Content/Main/V2/ContentMainBannerViewV2.swift @@ -14,7 +14,7 @@ struct ContentMainBannerViewV2: View { let bannerList: [GetAudioContentBannerResponse] @State var currentIndex = 0 - @State var timer = Timer.publish(every: 3, on: .main, in: .common).autoconnect() + @State var timer = Timer.publish(every: 4, on: .main, in: .common).autoconnect() @State var width: CGFloat = 0 @State var height: CGFloat = 0 @@ -68,7 +68,7 @@ struct ContentMainBannerViewV2: View { .onAppear { width = screenSize().width - 26.7 height = width * 0.53 - timer = Timer.publish(every: 3, on: .main, in: .common).autoconnect() + timer = Timer.publish(every: 4, on: .main, in: .common).autoconnect() } .onDisappear { timer.upstream.connect().cancel() @@ -107,28 +107,45 @@ struct ContentMainBannerImageView: View { let width: CGFloat let height: CGFloat let item: GetAudioContentBannerResponse + @State private var boundURL: URL? var body: some View { - KFImage(URL(string: url)) - .cancelOnDisappear(true) - .downsampling(size: CGSize(width: width, height: height)) - .resizable() - .scaledToFill() - .frame(width: width, height: height) - .cornerRadius(4.7) - .onTapGesture { - switch item.type { - case .EVENT: - AppState.shared.setAppStep(step: .eventDetail(event: item.eventItem!)) - case .CREATOR: - AppState.shared.setAppStep(step: .creatorDetail(userId: item.creatorId!)) - case .SERIES: - AppState.shared.setAppStep(step: .seriesDetail(seriesId: item.seriesId!)) - case .LINK: - if let link = item.link, link.trimmingCharacters(in: .whitespaces).count > 0, let url = URL(string: link), UIApplication.shared.canOpenURL(url) { - UIApplication.shared.open(url) - } + Group { + if let boundURL { + KFImage(boundURL) + .cancelOnDisappear(true) + .downsampling(size: CGSize(width: width, height: height)) + .resizable() + .scaledToFill() + .frame(width: width, height: height) + .cornerRadius(4.7) + } else { + Color.clear + .frame(width: width, height: height) + .cornerRadius(4.7) + } + } + .contentShape(Rectangle()) + .onTapGesture { + switch item.type { + case .EVENT: + AppState.shared.setAppStep(step: .eventDetail(event: item.eventItem!)) + case .CREATOR: + AppState.shared.setAppStep(step: .creatorDetail(userId: item.creatorId!)) + case .SERIES: + AppState.shared.setAppStep(step: .seriesDetail(seriesId: item.seriesId!)) + case .LINK: + if let link = item.link, link.trimmingCharacters(in: .whitespaces).count > 0, let url = URL(string: link), UIApplication.shared.canOpenURL(url) { + UIApplication.shared.open(url) } } + } + .onAppear { + let encoded = url.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? url + boundURL = URL(string: encoded) + } + .onDisappear { + boundURL = nil + } } } diff --git a/SodaLive/Sources/Live/EventBanner/SectionEventBannerView.swift b/SodaLive/Sources/Live/EventBanner/SectionEventBannerView.swift index d24b2f3..0c43cfb 100644 --- a/SodaLive/Sources/Live/EventBanner/SectionEventBannerView.swift +++ b/SodaLive/Sources/Live/EventBanner/SectionEventBannerView.swift @@ -11,7 +11,7 @@ import Kingfisher struct SectionEventBannerView: View { @State private var currentIndex = 0 - @State private var timer = Timer.publish(every: 3, on: .main, in: .common).autoconnect() + @State private var timer = Timer.publish(every: 4, on: .main, in: .common).autoconnect() @AppStorage("token") private var token: String = UserDefaults.string(forKey: UserDefaultsKey.token) let items: [EventItem] @@ -21,51 +21,13 @@ struct SectionEventBannerView: View { TabView(selection: $currentIndex) { ForEach(0.. 0, let url = URL(string: link), UIApplication.shared.canOpenURL(url) { - UIApplication.shared.open(url) - } - } else { - AppState.shared.setAppStep(step: .login) - } - } - } else { - KFImage(URL(string: item.thumbnailImageUrl)) - .cancelOnDisappear(true) - .resizable() - .scaledToFill() - .frame( - width: screenSize().width, - height: screenSize().width * 300 / 1000, - alignment: .center - ) - .tag(index) - .onTapGesture { - if !token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - if let _ = item.detailImageUrl { - AppState.shared.setAppStep(step: .eventDetail(event: item)) - } else if let link = item.link, link.trimmingCharacters(in: .whitespaces).count > 0, let url = URL(string: link), UIApplication.shared.canOpenURL(url) { - UIApplication.shared.open(url) - } - } else { - AppState.shared.setAppStep(step: .login) - } - } - } + SectionEventBannerPage( + item: item, + width: screenSize().width, + height: screenSize().width * 300 / 1000, + token: token + ) + .tag(index) } } .tabViewStyle(PageTabViewStyle(indexDisplayMode: .never)) @@ -85,7 +47,7 @@ struct SectionEventBannerView: View { } } .onAppear { - timer = Timer.publish(every: 3, on: .main, in: .common).autoconnect() + timer = Timer.publish(every: 4, on: .main, in: .common).autoconnect() } .onDisappear { timer.upstream.connect().cancel() @@ -109,3 +71,51 @@ struct SectionEventBannerView_Previews: PreviewProvider { SectionEventBannerView(items: []) } } + +private struct SectionEventBannerPage: View { + let item: EventItem + let width: CGFloat + let height: CGFloat + let token: String + @State private var boundURL: URL? + + var body: some View { + Group { + if let boundURL { + KFImage(boundURL) + .cancelOnDisappear(true) + .downsampling(size: CGSize(width: width, height: height)) + .resizable() + .scaledToFill() + .frame(width: width, height: height, alignment: .center) + } else { + Color.clear + .frame(width: width, height: height, alignment: .center) + } + } + .contentShape(Rectangle()) + .onTapGesture { + if !token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + if let _ = item.detailImageUrl { + AppState.shared.setAppStep(step: .eventDetail(event: item)) + } else if let link = item.link, + link.trimmingCharacters(in: .whitespaces).count > 0, + let url = URL(string: link), + UIApplication.shared.canOpenURL(url) { + UIApplication.shared.open(url) + } + } else { + AppState.shared.setAppStep(step: .login) + } + } + .onAppear { + let urlString = item.thumbnailImageUrl.addingPercentEncoding( + withAllowedCharacters: .urlQueryAllowed + ) ?? item.thumbnailImageUrl + boundURL = URL(string: urlString) + } + .onDisappear { + boundURL = nil + } + } +}