AsyncFlow는 RxFlow에서 영감을 받아 Swift Concurrency로 완전히 재설계한 네비게이션 프레임워크입니다.
복잡한 네비게이션 로직을 선언적으로 관리하고, Flow 기반의 모듈화된 아키텍처를 제공합니다.
- ✅ Swift Concurrency 기반: async/await와 AsyncStream을 활용한 현대적인 비동기 처리
- ✅ 선언적 네비게이션: Step을 통한 명확한 의도 표현
- ✅ 모듈화: Flow 단위로 네비게이션 영역을 분리하여 관리
- ✅ AsyncViewModel 친화적: 단방향 데이터 흐름과 자연스럽게 통합
- ✅ 딥링크 지원: 외부에서 Step을 주입하여 딥링크 처리 가능
- ✅ 테스트 가능: FlowTestStore를 통한 네비게이션 로직 테스트 지원
| 특징 | RxFlow | AsyncFlow |
|---|---|---|
| 비동기 처리 | RxSwift Observable | Swift Concurrency (async/await) |
| Step 스트림 | PublishRelay | AsyncStream |
| 메모리 관리 | DisposeBag | Task 자동 정리 |
| 플랫폼 지원 | iOS 전용 | iOS 전용 |
| 의존성 | RxSwift, RxCocoa | 없음 (Swift 표준 라이브러리만 사용) |
AsyncFlow는 Swift Package Manager를 통해 설치할 수 있습니다.
Package.swift 파일에 다음을 추가하세요:
dependencies: [
.package(url: "https://github.com/Jimmy-Jung/AsyncFlow", from: "1.0.0")
]또는 Xcode에서:
- File > Add Package Dependencies...
https://github.com/Jimmy-Jung/AsyncFlow입력- "Up to Next Major Version" 선택 후 1.0.0 입력
Tuist 프로젝트에서 사용하려면:
// Tuist/Package.swift
let packageSettings = PackageSettings(
productTypes: [
"AsyncFlow": .framework
]
)
// Project.swift
targets: [
.target(
name: "YourApp",
dependencies: [
.external(name: "AsyncFlow")
]
)
]AsyncFlow는 6가지 핵심 타입으로 구성됩니다.
네비게이션 의도를 표현하는 프로토콜입니다.
enum MovieStep: Step {
case movieList
case movieDetail(id: Int)
case castDetail(id: Int)
case unauthorized
}Step은 네비게이션 독립적이어야 합니다:
- ❌
showMovieDetail(id: Int)- 특정 화면 표시를 강제 - ✅
movieDetail(id: Int)- 의도만 표현, Flow가 표시 방법 결정
이를 통해 같은 Step이라도 Flow에 따라 다르게 표현될 수 있습니다. (예: iPad에서는 SplitView, iPhone에서는 Push)
Step을 방출하는 주체 (주로 ViewModel)입니다.
@MainActor
final class MovieListViewModel: ObservableObject, FlowStepper {
@Steps var steps
@Published var state = State()
enum Input: Sendable {
case movieTapped(id: Int)
}
struct State: Equatable, Sendable {
var movies: [Movie] = []
}
func send(_ input: Input) {
switch input {
case let .movieTapped(id):
steps.send(MovieStep.movieDetail(id: id)) // ← Step 방출!
}
}
}화면에 표시될 수 있는 것을 추상화하는 프로토콜입니다.
// UIViewController는 자동으로 Presentable을 구현합니다
extension UIViewController: Presentable {}
// Flow도 Presentable입니다
protocol Flow: AnyObject, Presentable {
var root: Presentable { get }
func navigate(to step: Step) -> FlowContributors
}네비게이션 영역 정의 및 Step → 네비게이션 액션 변환을 담당합니다.
final class MovieFlow: Flow {
// MARK: - Properties
var root: Presentable { navigationController }
private let navigationController = UINavigationController()
// MARK: - Flow Protocol
func navigate(to step: Step) -> FlowContributors {
guard let step = step as? MovieStep else { return .none }
switch step {
case .movieList:
return navigateToMovieList()
case .movieDetail(let id):
return navigateToMovieDetail(id: id)
case .castDetail(let id):
return navigateToCastDetail(id: id)
case .unauthorized:
return showUnauthorizedAlert()
}
}
// MARK: - Navigation Methods
private func navigateToMovieList() -> FlowContributors {
let viewModel = MovieListViewModel()
let viewController = MovieListViewController(viewModel: viewModel)
navigationController.setViewControllers([viewController], animated: false)
return .one(flowContributor: .contribute(
withNextPresentable: viewController,
withNextStepper: viewModel
))
}
private func navigateToMovieDetail(id: Int) -> FlowContributors {
let viewModel = MovieDetailViewModel(movieId: id)
let viewController = MovieDetailViewController(viewModel: viewModel)
navigationController.pushViewController(viewController, animated: true)
return .one(flowContributor: .contribute(
withNextPresentable: viewController,
withNextStepper: viewModel
))
}
}다음 Stepper와 Presentable을 연결합니다.
// 단일 Contributor
return .one(flowContributor: .contribute(
withNextPresentable: viewController,
withNextStepper: viewModel
))
// 여러 Contributor (예: TabBar)
return .multiple(
.contribute(withNextPresentable: movieFlow, withNextStepper: movieStepper),
.contribute(withNextPresentable: watchedFlow, withNextStepper: watchedStepper)
)
// 현재 Flow에 Step 전달
return .one(flowContributor: .forwardToCurrentFlow(withStep: MovieStep.home))
// 부모 Flow에 Step 전달
return .one(flowContributor: .forwardToParentFlow(withStep: AppStep.logout))
// Flow 종료 및 부모에 Step 전달
return .end(forwardToParentFlowWithStep: AppStep.onboardingComplete)전체 네비게이션을 조율하는 코디네이터입니다.
@main
class AppDelegate: UIResponder, UIApplicationDelegate {
var window: UIWindow?
let coordinator = FlowCoordinator()
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
window = UIWindow(frame: UIScreen.main.bounds)
let appFlow = AppFlow(window: window!)
let appStepper = OneStepper(withSingleStep: MovieStep.movieList)
// 네비게이션 이벤트 구독 (선택사항)
Task {
for await event in coordinator.didNavigate {
print("네비게이션 완료: \(event)")
}
}
coordinator.coordinate(flow: appFlow, with: appStepper)
return true
}
}enum AppStep: Step {
case launch
case login
case home
}final class AppFlow: Flow {
var root: Presentable { window }
private let window: UIWindow
init(window: UIWindow) {
self.window = window
}
func navigate(to step: Step) -> FlowContributors {
guard let step = step as? AppStep else { return .none }
switch step {
case .launch:
return navigateToLogin()
case .login:
return navigateToLogin()
case .home:
return navigateToHome()
}
}
private func navigateToLogin() -> FlowContributors {
let viewModel = LoginViewModel()
let viewController = LoginViewController(viewModel: viewModel)
window.rootViewController = viewController
window.makeKeyAndVisible()
return .one(flowContributor: .contribute(
withNextPresentable: viewController,
withNextStepper: viewModel
))
}
private func navigateToHome() -> FlowContributors {
let viewModel = HomeViewModel()
let viewController = HomeViewController(viewModel: viewModel)
window.rootViewController = viewController
return .one(flowContributor: .contribute(
withNextPresentable: viewController,
withNextStepper: viewModel
))
}
}@MainActor
final class LoginViewModel: ObservableObject, FlowStepper {
@Steps var steps
@Published var state = State()
struct State: Equatable, Sendable {
var email: String = ""
var password: String = ""
var isLoading: Bool = false
}
func login() async {
state.isLoading = true
// 로그인 로직...
state.isLoading = false
steps.send(AppStep.home) // 홈으로 이동
}
}let coordinator = FlowCoordinator()
let appFlow = AppFlow(window: window)
let appStepper = OneStepper(withSingleStep: AppStep.launch)
coordinator.coordinate(flow: appFlow, with: appStepper)AsyncFlow는 네비게이션 스택을 추적하고 로깅할 수 있는 기능을 제공합니다.
// 콘솔에 로그 출력
let coordinator = FlowCoordinator(logger: ConsoleFlowLogger())출력 형식:
🔄 Navigation willShow: loginSuccess
📚 Stack updated: loginStart → emailInput → passwordInput → loginSuccess
외부 로깅 시스템(OSLog, Firebase, Sentry 등)을 연동할 수 있습니다.
import OSLog
final class OSLogFlowLogger: FlowLogger {
private let logger = Logger(subsystem: "com.myapp", category: "navigation")
func log(navigationStack: NavigationStack) {
logger.info("""
Flow: \(navigationStack.flowName)
Steps: \(navigationStack.steps.map(\.caseDescription).joined(separator: " -> "))
Depth: \(navigationStack.depth)
""")
}
}
// 사용
let coordinator = FlowCoordinator(logger: OSLogFlowLogger())final class FirebaseFlowLogger: FlowLogger {
func log(navigationStack: NavigationStack) {
Analytics.logEvent("navigation", parameters: [
"flow": navigationStack.flowName,
"depth": navigationStack.depth,
"current_step": navigationStack.steps.last?.caseDescription ?? "none",
"path": navigationStack.steps.map(\.caseDescription).joined(separator: "->")
])
}
}기본적으로 로깅은 비활성화되어 있습니다.
// 로거를 지정하지 않으면 NoOpFlowLogger 사용 (로그 출력 없음)
let coordinator = FlowCoordinator()권한 체크, 로그인 확인 등의 로직을 구현할 수 있습니다.
func adapt(step: Step) async -> Step {
guard let movieStep = step as? MovieStep else { return step }
switch movieStep {
case .movieDetail:
// 권한 체크
if await PermissionManager.isAuthorized() {
return step
} else {
return MovieStep.unauthorized
}
default:
return step
}
}외부에서 Step을 주입하여 딥링크를 처리할 수 있습니다.
// URL 스킴으로부터 Step 생성
func handleDeepLink(_ url: URL) {
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
let movieId = components.queryItems?.first(where: { $0.name == "id" })?.value,
let id = Int(movieId) else { return }
// FlowCoordinator에 Step 주입
coordinator.navigate(to: MovieStep.movieDetail(id: id))
}AsyncFlow는 AsyncViewModel과 자연스럽게 통합됩니다.
@AsyncViewModel
final class LoginViewModel: FlowStepper {
@Steps var steps
enum Input: Equatable, Sendable {
case login(email: String, password: String)
}
enum Action: Equatable, Sendable {
case login(email: String, password: String)
case loginSuccess
case loginFailure(String)
}
struct State: Equatable, Sendable {
var email: String = ""
var password: String = ""
var isLoading: Bool = false
var errorMessage: String?
}
enum CancelID: Hashable, Sendable {
case login
}
func transform(_ input: Input) -> [Action] {
switch input {
case let .login(email, password):
return [.login(email: email, password: password)]
}
}
func reduce(state: inout State, action: Action) -> [AsyncEffect<Action, CancelID>] {
switch action {
case let .login(email, password):
state.isLoading = true
state.errorMessage = nil
return [
.run(id: .login) {
try await authService.login(email: email, password: password)
return .loginSuccess
} catch: { error in
return .loginFailure(error.localizedDescription)
}
]
case .loginSuccess:
state.isLoading = false
steps.send(AppStep.home) // ← Step 방출!
return []
case let .loginFailure(message):
state.isLoading = false
state.errorMessage = message
return []
}
}
func handleError(_ error: SendableError) {
print("에러: \(error.localizedDescription)")
}
}FlowStepper 프로토콜을 채택할 때 @Steps property wrapper를 사용하면 steps Subject를 자동으로 생성할 수 있습니다.
Flow는 다른 Flow를 자식으로 시작할 수 있습니다.
private func startMovieFlow() -> FlowContributors {
let movieFlow = MovieFlow()
let movieStepper = OneStepper(withSingleStep: MovieStep.movieList)
// movieFlow는 Presentable이면서 Flow입니다
return .one(flowContributor: .contribute(
withNextPresentable: movieFlow,
withNextStepper: movieStepper
))
}여러 FlowStepper를 조합할 수 있습니다.
let stepper1 = OneStepper(withSingleStep: AppStep.launch)
let stepper2 = someViewModel // FlowStepper 구현체
let compositeStepper = CompositeStepper(steppers: [stepper1, stepper2])
coordinator.coordinate(flow: appFlow, with: compositeStepper)AsyncFlowExample 프로젝트에서 실전 사용법을 확인할 수 있습니다.
- ✅ 화면 간 네비게이션 (A → B → C → D → E)
- ✅ N단계 뒤로 가기 (1단계, 2단계, 3단계)
- ✅ 특정 화면으로 점프 (A → E)
- ✅ 딥링크 시뮬레이션
- ✅ NavigationStack 시각화 (SwiftUI)
- Tuist 설치
curl -Ls https://install.tuist.io | bash- 프로젝트 생성 및 실행
cd AsyncFlow
tuist install
tuist generate
open AsyncFlow.xcworkspace또는 Tuist로 직접 실행:
tuist run AsyncFlowExamplesequenceDiagram
participant User
participant View
participant ViewModel
participant Coordinator as FlowCoordinator
participant Flow
User->>View: Tap Movie Cell
View->>ViewModel: send(.movieTapped(id: 1))
ViewModel->>ViewModel: steps.send(.movieDetail(id: 1))
ViewModel->>Coordinator: Step 방출
Coordinator->>Flow: navigate(to: .movieDetail(id: 1))
Flow->>Flow: navigateToMovieDetail(id: 1)
Flow->>Flow: Push MovieDetailViewController
Flow-->>Coordinator: .one(.contribute(presentable:stepper:))
Coordinator->>ViewModel: 새로운 Stepper 구독
AsyncFlow는 FlowTestStore를 통해 네비게이션 로직을 쉽게 테스트할 수 있습니다.
import Testing
import AsyncFlow
@testable import YourApp
@Test
@MainActor
func testMovieFlowNavigation() async {
// Given
let flow = MovieFlow()
let store = FlowTestStore(flow: flow)
// When
let contributors = store.navigate(to: MovieStep.movieList)
// Then
#expect(store.steps == [MovieStep.movieList])
if case .one(.contribute(let presentable, let stepper, _, _)) = contributors {
#expect(presentable.viewController is MovieListViewController)
#expect(stepper is MovieListViewModel)
}
}@Test
@MainActor
func testStepEmission() async throws {
// Given
let mockStepper = MockStepper()
let collectionTask = Task {
var steps: [Step] = []
for await step in mockStepper.steps.stream {
steps.append(step)
if steps.count == 2 { break }
}
return steps
}
// 구독 시작 대기
try await Task.sleep(nanoseconds: 10_000_000)
// When
mockStepper.emit(MovieStep.movieList)
mockStepper.emit(MovieStep.movieDetail(id: 1))
let receivedSteps = await collectionTask.value
// Then
#expect(receivedSteps.count == 2)
}# Xcode에서 실행
Command + U
# 커맨드라인
tuist test AsyncFlow- API 레퍼런스 (DocC)
- iOS 15.0+
- Swift 6.0+
- Xcode 16.0+
- visionOS 지원
- watchOS 지원
- 네비게이션 디버깅 도구
- Coordinator 간 통신 API
- Flow 애니메이션 커스터마이징
기여는 언제나 환영합니다!
- Fork the Project
- Create your Feature Branch (
git checkout -b feature/AmazingFeature) - Commit your Changes (
git commit -m 'Add some AmazingFeature') - Push to the Branch (
git push origin feature/AmazingFeature) - Open a Pull Request
자세한 내용은 CONTRIBUTING.md를 참조하세요.
AsyncFlow는 MIT 라이선스로 배포됩니다. 자세한 내용은 LICENSE 파일을 참조하세요.
AsyncFlow는 다음 프로젝트에서 영감을 받았습니다:
- RxFlow - Reactive Flow Coordinator pattern
- AsyncViewModel - 단방향 데이터 흐름
- The Composable Architecture - Effect 패턴
- 질문이나 제안이 있으신가요? GitHub Issues에 남겨주세요.
- 버그를 발견하셨나요? Bug Report를 작성해주세요.
- 새로운 기능을 제안하시나요? Feature Request를 작성해주세요.
Made with ❤️ and ☕ in Seoul, Korea