# 화면전환 최적화와 메모리 누수문제 해결
> 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()
}
}
}
```

## ⚙️ 기술 적용
- 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>
## ❇️ 적용 결과
