---
# System prepended metadata

title: 화면전환 최적화와 메모리 누수문제 해결
tags: [BuysellStandards]

---

# 화면전환 최적화와 메모리 누수문제 해결

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