+1

Giới thiệu về Coordinator pattern trong SwiftUI

Bài viết này nói về giải pháp xử lý các luồng phức tạp trong ứng dụng SwiftUI từ iOS 13.0+. Navigation trong ứng dụng SwiftUI có thể trở thành một điểm khó khăn.

Khi nói đến pattern hữu ích để xử lý luồng của application, coordinator phải nằm trong số đó. Bài viết này sẽ chỉ cho bạn cách tích hợp coordinator pattern với một Declarative framework như SwiftUI. Bài viết sẽ đi qua navigation system bằng cách giải thích từng phần và tìm giải pháp áp dụng Coordinator pattern để khắc phục vấn đề điều hướng. Nếu hiểu khái niệm này, bạn có thể map nó với Jetpack Compose hoặc các framework khác.

“Giới thiệu về coordinator pattern trong SwiftUI” là một phần trong loạt bài viết trong đó bạn học cách xử lý nhiều coordinator, cách coordinator con có thể giao tiếp với cha của nó, cách xử lý UITabBarControllers, các flow khác nhau và smart coordinator có thể chọn flow phù hợp cho ứng dụng của bạn.

Trong bài viết này, chỉ mô tả một phần của coordinator pattern chịu trách nhiệm routing. Vì vậy, bạn có thể đọc các bài viết liên quan để tìm hiểu sâu hơn về coordinator pattern như:

  • Hướng dẫn toàn diện to Coordinator pattern
  • Smart Coordinators
  • Deeplinking with Coordinators

Navigation = State

Nhiều markup language, chẳng hạn như HTML hoặc giao diện người dùng khác, thường mang tính khai báo. HTML chỉ mô tả những gì sẽ xuất hiện trên một trang web. Nó không chỉ định flow control để hiển thị một trang.

Tuy nhiên, trong imperative programming language,một câu lệnh dẫn đến việc lựa chọn một trong hai hoặc nhiều đường đi để theo.. Đó là lý do tại sao các hướng dẫn trong Coordinator pattern hoạt động tốt trong imperative languages.

Trích dẫn Matt Carroll trong bài viết này định nghĩa State rất rõ:

The behavior of the app at a given moment in time

App(t) = State

Vì vậy, chúng ta có thể coi Navigation là State? Navigation State có thể là một giá trị duy nhất tại một thời điểm thay đổi do ảnh hưởng của function khác. Tiếp theo, các vấn đề sẽ được xác định nếu bạn chọn Navigation làm State!

Problem Statement

Download project để làm theo các bước dễ dàng hơn: link

Chúng ta biết rằng SwiftUI là một declarative framework nên sẽ có các State và có một số hạn chế trong framework khi chuyển từ screen 1 sang 2, 2 sang 3, 3 sang 4, …, n-1 sang n . Một số vấn đề lớn sẽ phát sinh!

Trong SwiftUI NavigationView, chúng ta bỏ lỡ việc pop to root, pop về màn hình trước, dismiss toàn bộ stack của navigation,... , replace giữa các view hoặc các tính năng và navigation khác sẽ thêm rất nhiều độ phức tạp cho mỗi màn hình.

Màn hình 2 phải giữ Navigation State từ screen 1 và screen 2 sẽ tạo hiệu ứng phụ để quay lại màn hình 1.

Thậm chí rất khó để giải thích, hãy xem ví dụ!

import SwiftUI

struct MapView: View {
    
    @State var showCity: Bool = false
    
    var body: some View {
        NavigationView {
            NavigationLink("Go to the city", isActive: $showCity) {
                CityView(isActive: $showCity)
            }
        }
    }
}

struct CityView: View {
    
    @Binding var isActive: Bool
    
    var body: some View {
        VStack {
            Text("Istanbul")
            Button("Back") {
                isActive = false
            }
        }.navigationBarHidden(true)
    }
}

image.png

Figure 1 trông như thế này: một trang chính (MapView) có một nút để điều hướng đến trang chi tiết (CityView) bằng cách push transition và một nút back bên trong trang chi tiết để điều hướng quay lại trang chính.

Sự hỗn loạn đang đến!

@State var showCity: Bool là Navigation State. Main page pass State đó sang detail view để có thể tác động lên detail view làm thay đổi lại điều hướng trong Main page. Vì vậy, trong cả hai trường hợp, push từ Main page đến detail view và pop từ detail view về Main page, Navigation State là tác nhân và được lưu trữ trong Main page!

Có một vấn đề khác. Điều gì sẽ xảy ra nếu có mười màn hình được liên kết với nhau với các State này và chúng tôi phải quay lại màn hình đầu tiên hoặc màn hình gốc? việc xử lý luồng này sẽ hoàn toàn lộn xộn!

Hãy tóm tắt:

  1. Navigation State của app là một State và mỗi lần chỉ có một giá trị. Mỗi lần, Navigation State của chúng ta có thể đại diện cho màn hình A và vào thời điểm khác, nó có thể là màn hình B.
  2. NavigationRouting là một cái side-effect phải thay đổi route của screen khác, vì vậy State là một Route vì nó thay đổi theo tác động của Navigation.

Như vậy, với sự giới thiệu ở trên thì nhằm để giải quyết vấn đề là gì?

Think Different

Navigation systems hiện tại là NavigationStack, NavigationView and UINavigationController.

NavigationView đã bị lỗi thời trong iOS 16.0, nhưng cho các phiên bản iOS mới, Apple đã giới thiệu một NavigationStack mới trong WWDC22. NavigationStack này được hỗ trợ từ iOS 16.0 trở lên và hoạt động tốt trên tất cả các nền tảng của Apple, tuy nhiên nó không tuân thủ mô hình Coordinator và không tương thích ngược với các phiên bản cũ.

Với NavigationStack mới, bạn có thể quay về gốc (root) hoặc quay lại một bước mà không cần truyền tham chiếu push của view trước đó. Tuy nhiên, bạn vẫn không thể đóng toàn bộ navigation stack từ một view ở giữa stack. Hành vi đóng chỉ hoạt động từ view gốc (root)!

So sánh với UINavigationController:

  • UINavigationController hoạt động từ iOS 2.0 trở đi, điều này tốt cho các ứng dụng kết hợp UIKit/SwiftUI, trong khi NavigationStack chỉ hoạt động từ iOS 16.0 trở lên.
  • UINavigationController có nhiều kiểm soát hơn về điều hướng so với NavigationStack.
  • UINavigationController linh hoạt hơn trong việc tùy chỉnh.
  • NavigationStack là mới và tương thích tốt hơn với các nền tảng khác nhau của Apple.

Từ góc nhìn thiết kế, UINavigationController trong UIKit khác với NavigationView trong SwiftUI. Chúng có cài đặt và cách sử dụng khác nhau.

Tuy nhiên, từ góc nhìn kỹ thuật, NavigationView trong SwiftUI chính là UINavigationController! Đúng vậy! UIKit và SwiftUI đều là các framework được đóng, và tài liệu của chúng không đề cập nhiều về cách các thành phần của chúng đã được tạo ra. Do đó, có rất nhiều sự không chắc chắn.

Tuy nhiên, khi xem xét sâu hơn về cấu trúc của NavigationView, chúng ta có thể khẳng định rằng chúng giống nhau!

image.png

Vâng, nếu tất cả đều là UINavigationController, thì có một quản lý điều hướng tuyệt vời, nhưng nó không thể được sử dụng như vậy. SwiftUI Views không thể được xếp chồng vào một UINavigationController! Nhưng chờ đã, cũng có một tin tốt! Nếu bạn kiểm tra lại Debug View Hierarchy, bạn sẽ thấy rằng MapView được nhúng trong một UIHostingController kế thừa từ UIViewController. image.png Có vẻ như cách tiếp cận trong UIKit cho Coordinators tương tự. NavigationView giữ các stack của View, giống như UINavigationController giữ các stack của UIViewController!

Soroush Khanlou đã giới thiệu một quản lý luồng điều hướng, mô hình Coordinator. Theo bài viết đó, ông có một cuộc trò chuyện sâu hơn về cách giải quyết vấn đề Massive View Controllers. Mô hình Coordinator là mô hình đơn giản và linh hoạt nhất để xử lý luồng điều hướng một khi bạn đã hiểu ý tưởng đằng sau nó!

Nghe có vẻ thú vị! Chúng ta có thể áp dụng mô hình Coordinator vào đó!

Implementation for Coordinators in SwiftUI

Như đã nêu, Coordinators (Hệ thống điều hướng) nên là một State (trạng thái) thay đổi thông qua hiệu ứng phụ của một function. Trình quản lý trạng thái có thể là một hàm để kích hoạt một giá trị one-shot cụ thể với hiệu ứng phụ thay đổi luồng điều hướng.

Một giá trị one-shot là một giá trị/tín hiệu xảy ra đúng một lần trong một khoảng thời gian và tạo ra một hiệu ứng phụ! Ví dụ, toán tử = trong máy tính là một giá trị/tín hiệu one-shot. Nó sẽ tính toán các phép tính và tạo ra một giá trị mới trên màn hình. Bạn có thể tưởng tượng hàm present/push của UIKit nhận một đối tượng duy nhất và tạo ra một hiệu ứng phụ cho vòng đời của ứng dụng.

Trong mô hình coordinator, các giá trị one-shot được gọi là 'Route' và chúng được lưu trữ trong các định nghĩa liệt kê (enumerations). Các hàm của Coordinator sẽ sử dụng các Route này, và mỗi Route nên có các yêu cầu về điều hướng.

Trong một ứng dụng đơn giản, một Route ít nhất nên chứa 'transition style' và 'destination view' của nó để chúng ta có thể nhận ra một giá trị của Route và cách nó có thể điều hướng ở đâu và như thế nào! Routes không chỉ là các đường dẫn, mà chúng có thể làm những gì đường dẫn làm được!

Hãy bắt đầu vào việc triển khai!

Đối với transition style, chúng ta xem xét một ứng dụng đơn giản chỉ có thể thực hiện push, presentModally hoặc presentFullScreen cho một Route duy nhất.

public enum NavigationTranisitionStyle {
    case push
    case presentModally
    case presentFullscreen
}

Đối với điểm đến (destination), nó phải quyết định view/path nào sẽ được tạo dựa trên Route của nó. Dưới đây là một đoạn code để hình dung tốt hơn những gì chúng ta đang muốn đạt được:

@ViewBuilder
func view(route: Route) -> View { 
   switch route { 
   case ...
       return SomeView()
}

Cuối cùng, tất cả các yêu cầu đã có sẵn trong mã giả, và chúng ta có thể đạt được mục tiêu chính của mình là 'Route' bằng cách đặt tất cả chúng trong một khái niệm mới của NavigationRouter!

Một lần nữa, một Route ít nhất nên chứa kiểu chuyển tiếp và view đích của nó, vì vậy thông qua trừu tượng hóa một Route, chúng ta có thể áp dụng những yêu cầu đó vào bất kỳ kiểu cụ thể nào khác.

import SwiftUI

public protocol NavigationRouter {
    
    associatedtype V: View

    var transition: NavigationTranisitionStyle { get }
    
    /// Creates and returns a view of assosiated type
    ///
    @ViewBuilder
    func view() -> V
}

Vì các route phải là one-shot, việc sử dụng liệt kê (enums) có ý nghĩa hơn! Những route này sẽ là điểm kích hoạt của Coordinator, trỏ tới một luồng điều hướng đơn lẻ. Nếu các bạn tùm hiểu về chủ đề 'Hướng dẫn toàn diện về mô hình Coordinator', bạn có thể tìm hiểu thêm về cách xử lý các luồng điều hướng khác nhau, nhiều UINavigationController, UITabBarController và các child coordinator.

import SwiftUI

public enum MapRouter: NavigationRouter {
    
    case map
    case city(named: String)
    
    public var transition: NavigationTranisitionStyle {
        switch self {
        case .map:
            return .push
        case .city:
            return .push
        }
    }
    
    @ViewBuilder
    public func view() -> some View {
        switch self {
        case .map:
            MapView()
        case .city(named: let name):
            CityView(name: name)
        }
    }
}

Với tất cả các RoutesUINavigationController, điều duy nhất còn lại là một đầu bếp nấu một bữa ăn và gọi nó là Coordinator!

Các developer không phải là các phù thủy, nhưng họ có khả năng tưởng tượng rất lớn! Họ tạo ra các ứng dụng bằng cách kết hợp các hướng dẫn và tiếp tục nỗ lực, thành công và thất bại từ quá khứ thông qua sức tưởng tượng và suy nghĩ sáng tạo.

Trước tiên, hãy tạo một trừu tượng của một Coordinator để hiểu về các chức năng cốt lõi của nó!

Nó phải có:

  • Một tham chiếu đến UINavigationController: Coordinator cần có một tham chiếu đến UINavigationController để thực hiện các hoạt động điều hướng.
  • Một hàm start để bắt đầu quy trình: Coordinator nên có một hàm start để khởi đầu quy trình điều hướng. Hàm này là điểm vào cho Coordinator.
  • Một hàm để hiển thị điểm đến của route: Coordinator nên cung cấp một hàm để xử lý hiển thị view đến cho một route cụ thể. Hàm này có trách nhiệm tạo ra view controller phù hợp và thực hiện các hoạt động điều hướng cần thiết.
  • Tất cả các chức năng của UINavigationController: Coordinator nên cung cấp các chức năng cần thiết để tương tác với ngăn xếp điều hướng, chẳng hạn như pop về màn hình trước đó, pop về root view controller, và dismiss navigationController.
  • Có thể được injectable vào mỗi điểm đến/view/page của Route:Coordinator nên có thể được injectable vào điểm đến/view/page tương ứng với mỗi Route. Tất cả các trang trong ngăn xếp điều hướng nên có một đối tượng Coordinator duy nhất!

EX:

protocol Coordinator: SomeInjectableProtocol {

    var navigationController: UINavigationController { get }
    
    func start()
    func show(_ route: AnyRoute) 
    func pop()
    func popToRoot()
    func dismiss()
}

Nhìn có vẻ như đã đủ. hãy implement protocol này!

import SwiftUI

open class Coordinator<Router: NavigationRouter>: ObservableObject {
    
    public let navigationController: UINavigationController
    public let startingRoute: Router?
    
    public init(navigationController: UINavigationController = .init(), startingRoute: Router? = nil) {
        self.navigationController = navigationController
        self.startingRoute = startingRoute
    }
    
    public func start() {
        guard let route = startingRoute else { return }
        show(route)
    }
    
    public func show(_ route: Router, animated: Bool = true) {
        let view = route.view()
        let viewWithCoordinator = view.environmentObject(self)
        let viewController = UIHostingController(rootView: viewWithCoordinator)
        switch route.transition {
        case .push:
            navigationController.pushViewController(viewController, animated: animated)
        case .presentModally:
            viewController.modalPresentationStyle = .formSheet
            navigationController.present(viewController, animated: animated)
        case .presentFullscreen:
            viewController.modalPresentationStyle = .fullScreen
            navigationController.present(viewController, animated: animated)
        }
    }
    
    public func pop(animated: Bool = true) {
        navigationController.popViewController(animated: animated)
    }
    
    public func popToRoot(animated: Bool = true) {
        navigationController.popToRootViewController(animated: animated)
    }
    
    open func dismiss(animated: Bool = true) {
        navigationController.dismiss(animated: true) { [weak self] in
            /// because there is a leak in UIHostingControllers that prevents from deallocation
            self?.navigationController.viewControllers = []
        }
    }
}

Hãy giải thích từng câu hỏi một cách chi tiết:

public let startingRoute: Router là gì?

Thuộc tính startingRoute kiểu public let đại diện cho route khởi đầu mà Coordinator sẽ bắt đầu. Đây là một hằng số công khai (public) lưu trữ một tham chiếu đến Router, xác định điểm bắt đầu của luồng điều hướng.

public func show(_ route: Router, animated: Bool = true) là gì?

Đây là chức năng chính của coordinator. Nó nhận vào một route, tạo ra điểm đến (destination), inject một coordinator vào điểm đến đó, và đặt view vào UIHostingController bằng cách sử dụng một kiểu View không rõ ràng (opaque). Kiểu View này dựa trên kiểu chuyển tiếp của route và điều hướng hệ thống đến điểm đến!

public func pop(animated: Bool = true) là gì?

Vì đây là một chức năng của UINavigationController, nó nên được truy cập từ đối tượng coordinator. Nó không liên quan gì đến route, chỉ đơn giản là thay đổi một số thứ trong luồng điều hướng.

public func popToRoot(animated: Bool = true) là gì?

Giống như pop, đây là một chức năng khác của navigationController.

open func dismiss(animated: Bool = true)là gì?

Đây là một trong những chức năng chính của UIViewController, nhưng có một số khác biệt. Trong kiến trúc này, UIHostingControllers sẽ không bị giải phóng bộ nhớ chỉ bằng cách loại bỏ navigationController. Tại thời điểm này, một rò rỉ bộ nhớ sẽ xảy ra.

Các hostingControllers sẽ được giữ lại vì vẫn còn tham chiếu mạnh đến navigationController, nhưng bằng cách loại bỏ thủ công các hostingControllers khỏi ngăn xếp điều hướng, vấn đề sẽ được giải quyết và tham chiếu của chúng đến navigationController sẽ bị hủy, từ đó hostingControllers sẽ được giải phóng bộ nhớ thành công.

Tại sao một số hàm lại là public, open hoặc private?

Điều này phụ thuộc vào nhu cầu của bạn, nhưng tôi đã tìm thấy việc đóng gói tốt nhất với kiểm soát truy cập này đối với các thuộc tính của Coordinator.

Tại sao nó là một Observable Object?

Như đã nói, nó nên được inject vào SwiftUI Views, vì vậy kiểu Observable Object là giải pháp duy nhất cho Dependency Injection trong SwiftUI Views. Nó sẽ được giải quyết như một @EnvironmentObject.

Trong khi Coordinator và Router có vẻ là những công cụ phù hợp, làm thế nào chúng ta đưa chúng vào vòng đời của ứng dụng?

Cách đơn giản nhất để đưa UINavigationController vào vòng đời của ứng dụng là sử dụng AppDelegate và SceneDelegate, như chúng ta đã sử dụng trong các ứng dụng dựa trên UIKit. Theo ý kiến chân thành của tôi, việc phân tách chúng và chức năng của chúng giúp giảm phức tạp cho hầu hết các tính năng phức tạp, ngay cả khi chúng ta có thể thêm nhiều cửa sổ vào màn hình!

Vì vậy, nếu chúng ta quay trở lại thời kỳ sử dụng AppDelegate và SceneDelegate, chúng ta sẽ có thể đặt UINavigationController vào UIWindow và khởi chạy ứng dụng!

Trước tiên, hãy loại bỏ file YOUR_PROJECT_NAMEApp.swift, đó là điểm vào chính (@main) của ứng dụng. Hãy vứt nó vào thùng rác!

**Thứ hai, **tạo AppDelegate.swift với cấu hình cho delegate kết nối. Đây là mẹo giúp kết nối lớp SceneDelegate với ứng dụng của bạn.

import UIKit

@main
final class AppDelegate: NSObject, UIApplicationDelegate {
    
    func application(
        _ application: UIApplication,
        didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
    ) -> Bool {
        return true
    }
    
    func application(
        _ application: UIApplication,
        configurationForConnecting connectingSceneSession: UISceneSession,
        options: UIScene.ConnectionOptions
    ) -> UISceneConfiguration {
        let sessionRole = connectingSceneSession.role
        let sceneConfig = UISceneConfiguration(name: nil, sessionRole: sessionRole)
        sceneConfig.delegateClass = SceneDelegate.self
        return sceneConfig
    }
}

LƯU Ý QUAN TRỌNG: Hãy nhớ rằng, nếu bạn build và chạy ứng dụng của mình với tệp YourApp.swift cũ (điểm vào chính @main của ứng dụng) trên một thiết bị trước đó, bạn nên hoàn toàn gỡ bỏ ứng dụng đó khỏi thiết bị và chạy lại dự án.

Thứ ba, tạo SceneDelegate.swift để sửa đổi window và chèn NavigationController đầu tiên của bạn vào đó.

import SwiftUI

final class SceneDelegate: NSObject, UIWindowSceneDelegate {
        
    private let coordinator: Coordinator<MapRouter> = .init(startingRoute: .map)
    
    var window: UIWindow?

    func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
        guard let windowScene = (scene as? UIWindowScene) else { return }
        window = UIWindow(windowScene: windowScene)
        window?.rootViewController = coordinator.navigationController
        window?.makeKeyAndVisible()
        coordinator.start()
    }
}

Sau khi ứng dụng được khởi chạy, window mới sẽ được tạo và coordinator sẽ được chuyển đến startedRoute khi thực thi hàm start.

Điều cuối cùng còn lại là cách sử dụng trong các view. Đây là code:

import SwiftUI

struct MapView: View {
    
    @EnvironmentObject var coordinator: Coordinator<MapRouter>

    var body: some View {
        NavigationView {
            Button("Go to the city") {
                coordinator.show(.city(named: "El Paso"))
            }
        }
    }
}

struct CityView: View {
    
    @EnvironmentObject var coordinator: Coordinator<MapRouter>
    
    let name: String
    
    var body: some View {
        VStack {
            Text(name)
            Button("Back") {
                coordinator.pop()
            }
        }.navigationBarHidden(true)
    }
}

Điều thú vị nhất là tất cả các ViewModifier mà có thể áp dụng cho NavigationView vẫn có thể áp dụng cho mẫu thiết kế này. Như việc ẩn navigationBar (navigationBarHidden), thêm navigationBarItems, v.v.

Cuối cùng, bạn có thể kiểm tra đoạn mã này với việc quản lý điều hướng dựa trên trạng thái cũ mà chúng ta đã có trước khi sử dụng mẫu thiết kế Coordinator. Không còn lo lắng về việc xử lý 100 trang, đi từ trang 73 đến trang 18 hoặc loại bỏ toàn bộ ngăn xếp điều hướng trên trang 99.

Đó chỉ là một giới thiệu về mẫu thiết kế Coordinator quan trọng. Trong các bài viết tiếp theo, tôi sẽ cố gắng tìm kiếm các bài viết liên quan đến các chủ đề như:

  • Cách xử lý nhiều Coordinators
  • Cách các coordinator con có thể giao tiếp với coordinator cha của chúng
  • Cách xử lý UITabBarController
  • Các luồng và Smart Coordinators khác nhau có sẵn cho luồng phù hợp với ứng dụng của bạn.

END

Link gốc bài viết: https://betterprogramming.pub/an-introduction-to-coordinator-pattern-in-swiftui-38e5b02f031f


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí