# 화면전환 최적화와 메모리 누수문제 해결 > AppState 를 최상단에서 주입시켜 각 화면의 Routing을 관리하는 방식 이용 ## AppState - 앱 계층에서 다룰 수 있는 객체들을 선언합니다. - 테스트 프로젝트에선 ViewRouting 객체만 이용하여 화면전환 로직을 관리하도록 했습니다. - 추후 SystemHandler와 같이 App LifeCycle에 적용할 객체를 생성할 예정입니다. <img src="https://hackmd.io/_uploads/HksSDnyva.png" width=500> ## App ```swift // AppEnvironment.swift struct AppEnvironment { let container: DIContainer } extension AppEnvironment { static func bootstrap() -> AppEnvironment { let appState = Store<AppState>(AppState()) let diContainer = DIContainer(appState: appState) return AppEnvironment(container: diContainer) } } // piece_iosApp.swift @main struct piece_iosApp: App { @UIApplicationDelegateAdaptor var delegate: AppDelegate // 앱의 모든 의존성 생성, 주입과정을 bootstrap 메서드 실행으로 let environment = AppEnvironment.bootstrap() init() { UserDefaults.standard.setValue(false, forKey: "isLookedPopup") } var body: some Scene { WindowGroup { NavigationStack { LaunchScreen() .inject(environment.container) // 주입 .environment(\.colorScheme, .light) .preferredColorScheme(.light) } } } } ``` ## Routing - 각 뷰마다 다음화면으로 이동하는 네비게이션 정보를 가집니다. - 아래는 피스앱 화면간 관계도 중 일부입니다. 화면간 연결된 하얀 선 모두가 Routing 정보에 해당합니다. <img src="https://hackmd.io/_uploads/Hk_3thyPp.png" width=800> <br> - ex) WalletTab은 (출금하기, 입금하기, 거래내역, 내 조각) 화면으로 이동정보를 Routing 객체가 가집니다. ```swift // WalletTab.swift extension WalletTab { struct Routing { var 출금하기로이동하기 = false var 입금하기로이동하기 = false var 거래내역으로이동하기 = false var 내조각으로이동하기 = false } } ``` - 위 Routing 정보는 Bool 타입 변수로 관리되고 True가 될 시, 다음화면으로 이동하는 형식입니다. - AppState가 각 화면의 Routing 정보를 가지고있고, AppState는 최상단에서 inject 메서드로 주입된 상태이니 어느 화면에서나 AppState에 접근하여 이 Routing 정보를 수정, 업데이트를 해줄 수 있습니다. ```swift struct Wallet_DepositWithdrawFailView: View { // 주입받은 appState @Environment(\.injected) private var injected: DIContainer var body: some View { ZStack { VStack(alignment: .center) { Spacer() Image("fail") .resizable() .frame(width: 150, height: 150, alignment: .trailing) .aspectRatio(contentMode: .fit) .foregroundColor(.white) .padding(.bottom, 16) Text("출금에 실패했어요") .generalFont(size: 26) .foregroundColor(.grayBlack) .padding(.bottom, 8) Text("잠시 후 다시 시도해주세요.") .generalFont(size: 14) .foregroundColor(.gray600) .multilineTextAlignment(.center) Spacer() Button { // MARK: 처음화면으로 돌아가는 설정 let routeDestination = { injected.appState.bulkUpdate { $0.routing.walletTab.toWithdraw = false } } routeDestination() } label: { Text("처음으로") .generalFont(size: Constants.fontSizeLargeButton, fontWeight: .w700) } .frame( height: Constants.heightLargeButton) .buttonStyle(LargePrimaryButtonStyle(isEnable: true)) .padding(.horizontal, 16) if Util.isNonNotchDevice() { Spacer().frame(height: 10) } } .hiddenNavigationBarStyle() } } } ``` ![스크린샷 2023-12-20 오전 11.06.29](https://hackmd.io/_uploads/ryfEn61D6.png) ## ⚙️ 기술 적용 - NavigationStack 을 적용합니다. - iOS 16 부터 기존에 사용하던 `NavigationLink` 중 일부 initializer가 deprecated 됩니다. - NavigationLink 들을 `.navigationDestination(:)` 메서드로 수정해야합니다. - popToRoot 기능이 필요한 routing 정보만 Routing 객체에 적용합니다. - 추후 모든 Routing 정보를 옮겨둘 예정입니다. (isPresent... 와 같은 State 변수들) ## 장점 - 클린아키텍쳐의 형태로 서서히 코드를 바꿔나갈 수 있습니다. - Application 레벨의 제어가 필요한 상태값들을 관리 가능합니다. - ex) 사용자 인증관련 변수가 화면마다 분포되어 있는데 이를 한 곳에서 관리하도록 수정 - ex) 네트워크가 끊겼을 때 처리해야 하는 fullScreen을 보여주는 관련 변수가 화면마다 분포되어 있는데 이를 한 곳에서 관리하도록 수정 - 딥링크 구현이 간편해집니다. - 기존 코드에서 변경사항이 많지 않아 빠르게 적용이 가능합니다. - `NavigationLink()` -> `.navigationDestination()` ## 🔧 사이드 이펙트 #### 스와이프 백 액션 수정 - 뷰에서 사용하던 제스쳐 관련 프로퍼티 래퍼`@DragGesture` 로 인해 적용과정 중 오류가 발생하여 스와이프백 액션을 구현할 수 있는 다른 방법을 연구했습니다. - [참고 블로그](https://medium.com/hcleedev/swift-custom-navigationview에서-swipe-back-가능하게-하기-c3c519c59bcb) 에서 UINavigationController를 확장하여 기존 UIKit 에서 사용하던 스와이프 백 액션을 사용할 수 있다는 점을 이용해 이를 적용했습니다. - `HomeView` 에서만 스와이프 백 액션이 일어나지 않도록 `SwipeBack ` Singleton 객체를 만들어 화면상태를 인지하고 액션을 수행하도록 제한을 주었습니다. #### openUrl 액션 수정 - `@Environment(\.openUrl)` 환경변수 때문에 오류가 발생함을 발견했습니다. - 이를 대체하기 위해 `UIApplication.shared` 로 접근하여 openUrl 메서드를 이용했습니다. #### 불필요한 @Binding 전달 대체 - 하위 뷰와 상위 뷰 간에 서로 데이터 변화에 영향을 주기 위해 @Binding 키워드로 파라미터를 넘겨주는 작업을 하는데, 이 조차 appState 에서 관리가 가능하여 불필요한 Binding 을 줄일 수 있었습니다. - 결과적으로 뷰 선언 시, 파라미터 개수가 줄거나 아예 없어졌습니다. ## 🛠️ Trouble Shooting > NavigationView 에서 NavigationStack 으로 바꾸는 과정에 생긴 오류를 기록합니다. #### @FocusState #### @Environment(\.openUrl) #### @Environment(\.dragGesture) ## Path 를 이용했을 때와 차이점 <table> <tr> <td align="center">종류</td> <td align="center">Path를 이용한 방법</td> <td align="center">CleanArchitecture 방법</td> </tr> <tr> <td align="center">사용하는 기술</td> <td> NavigationStack, NavigationPath </td> <td> NavigationStack </td> </tr> <tr> <td align="center">데이터 전달</td> <td> navigator 객체 내부 Dictionary 변수에 <br> Any 타입의 Value로 저장 후 <br> 강제 캐스팅으로 전달받은 데이터 타입을 구체화 </td> <td> 기존과 동일 </td> </tr> <tr> <td align="center">화면전환 관리</td> <td> navigator 객체가 pathId 배열을 소유 <br> pathId 배열 내 id 값들만 관리해주면 됨 </td> <td> 특정 화면의 `isPresent` 바인딩변수를 <br> true/false 로 변경해주는 작업을 통해 <br> 화면전환 </td> </tr> <tr> <td align="center">장점</td> <td> - navigator 객체가 화면전환 로직을 <br> 담당하게 되어 관심사가 분리된다. <br> - 배열로 화면순서를 관리하기 때문에 <br> 자유로운 화면전환이 가능하다 <br> - 읽기 쉬운 코드가 된다. <br> </td> <td> - 기존 코드에서 많은 변경이 일어나지 않는다. <br> - 특정 화면위에 어떤 sheet를 올릴지, <br> 어떤 화면으로 이동할지 구체적인 제어가 가능하다. </td> </tr> <tr> <td align="center">단점</td> <td> - 데이터 전달에 신뢰성이 부족하다 <br> (as! 강제 캐스팅) <br> - 각 화면당 Builder 객체가 만들어 진다. <br> - 어떤 Builder가 build 해야할 지 <br> filter 함수를 이용해 배열에서 찾아내는 <br> 작업에 O(n) 시간복잡도를 가진다. </td> <td> - CleanArchitecture에 대한 배경지식 <br> 없이 이해하기 쉽지 않다. <br> - 앱 전반적인 상태값에 어디에서나 접근이 <br> 가능하기 때문에 상태값 변경에 노출되는 위험이 있다. <br> </td> </tr> </table> #### 기본 화면전환 코드 ``` 청약 홈에서 청약 리스트중 하나를 누를 때 ``` > NavigationStack Path 예시 ```swift let navigator: NavigatorProtocol @State private var selecgtedPortfolioId VStack { PortfolioListRow() .onTapGesture { navigator.present( path: PortfolioDetailView.pathId, item: ["portfolioId": selectedPortfolioId] ) } } ``` > CleanArchitecture 예시 ```swift @State private var isPresentPortfolioDetailView = false @State private var selecgtedPortfolioId VStack { PortfolioListRow() .onTapGesture { isPresentPortfolioDetailView = true } } .navigationDestination(isPresent: $isPresentPortfolioDetailView) { PortfolioDetailView(portfolioId: selectedPortfolioId) } ``` #### 커스텀 화면전환 코드 ``` 청약신청 플로우에서 성공/실패화면 -> 다시 청약홈으로 돌아가는 액션 ``` > NavigationStack Path 예시 ```swift let navigator: NavigatorProtocol VStack { Button { navigator.dismissToPath( path: PortfolioDetailView.pathId, isResetItem: true ) } label: { Text("처음화면으로 돌아가기 버튼") } } ``` > CleanArchitecture 예시 ```swift @Environment(\.injected) private var injected VStack { Button { injected.appState.bulkUpdate { $0.routing.portfolioTab.toPortfolioDetail = false } } label: { Text("처음화면으로 돌아가기 버튼") } } ``` ## ⚙️ 사용된 핵심 기술 개념정리 ### [적용전략](https://hackmd.io/@VPwqi9oPSIqZAk8f88QkkA/B1xL6XB_a) ### Store <details> ```swift typealias Store<State> = CurrentValueSubject<State, Never> ``` - 기본적으로 Combine 프레임워크를 기반으로 한 Store 타입입니다. - `CurrentValueSubject` 는 단일 값이 바뀔 때마다 새로운 발행(Publish)을 하는 타입입니다. ```swift extension Store { subscript<T>(keyPath: WritableKeyPath<Output, T>) -> T where T: Equatable { get { value[keyPath: keyPath] } set { var value = self.value if value[keyPath: keyPath] != newValue { value[keyPath: keyPath] = newValue self.value = value } } } func bulkUpdate(_ update: (inout Output) -> Void) { var value = self.value update(&value) self.value = value } func updates<Value>(for keyPath: KeyPath<Output, Value>) -> AnyPublisher<Value, Failure> where Value: Equatable { return map(keyPath).removeDuplicates().eraseToAnyPublisher() } } ``` - subscript 메서드를 Store타입의 확장함수로 정의하여 배열에서 사용하는 `[]` 문법을 사용 가능하게 만들었습니다. - `bulkUpdate()` 메서드는 value 값을 업데이트 시켜주는 역할을 합니다. - `WritableKeyPath` 에 대한 자세한 내용은 아래 문서를 참고해주세요. - [The Swift Programming Language -> Expressions -> Keypath-Expression](https://docs.swift.org/swift-book/ReferenceManual/Expressions.html#ID563) ```swift extension Binding where Value: Equatable { func dispatched<State>(to state: Store<State>, _ keyPath: WritableKeyPath<State, Value>) -> Self { return onSet { state[keyPath] = $0 } } } extension Binding where Value: Equatable { typealias ValueClosure = (Value) -> Void func onSet(_ perform: @escaping ValueClosure) -> Self { return .init(get: { () -> Value in self.wrappedValue }, set: { value in if self.wrappedValue != value { self.wrappedValue = value } perform(value) }) } } ``` - SwiftUI 프레임워크 내부에 존재하는 `Binding<Value>` 구조체의 확장함수 `dispatched()` 를 정의했습니다. - `onSet()` 메서드에서 set을 정의합니다. 새로운 값과 기존 값이 다르면 새로운 값으로 대체를 해줍니다. - 결론은 dispatched 메서드도 마찬가지로 keyPath에 해당하는 value 값을 업데이트 시켜주는 역할을 합니다. </details> ## ⚠️ 기존 잘못된 네비게이션 흐름 목록 <details> #### 둘러보기 - 둘러보기 플로우 중 "로그인이 필요한 서비스에요" 화면 -> 로그인 화면 #### 청약 탭 - (가상계좌가 없을 때)청약신청 버튼 탭 -> 연동계좌 만들기 -> 청약 상세화면? - 청약신청 플로우 중 "신청 실패 화면" -> 청약상세화면 - 청약신청 플로우 중 "신청 성공 화면" -> 청약리스트 화면 - 청약탭 중 알림목록 보기에서 "알림Row 탭" -> 청약탭 or 내지갑 탭 #### 매거진 탭 - (북마크목록이 없을 때) 매거진탭 중 북마크목록 보기에서 "매거진 둘러보기" -> 매거진 탭 #### 내지갑 탭 - 연동계좌 신청/변경 플로우 중 "신청/변경 성공 화면" -> 내지갑 탭 - 출금신청 플로우 중 "출금 성공/실패 화면" -> 내지갑 탭 #### 더보기 탭 - 회원탈퇴 플로우 중 "예치금 출금 신청하기", "소유조각 보러가기" -> 내지갑 탭 - 마이페이지 - 로그아웃 시 #### 기타 <!-- - AuthView 비밀번호 재설정 -> IdentificationView 이동 --> - (다른기기에서 로그인 시) 탭 이동 -> InitView로 이동 - goToAuthView() 에서 검증 실패시 NotFoundView 화면 -> 처음으로 버튼 탭 </details> ## ❇️ 적용 결과 ![스크린샷 2024-05-11 오후 12.00.59](https://hackmd.io/_uploads/ryYdyv3M0.png)