Etiqueta: Dessign Pattern

  • Dependency Injection implementations in Swift

    Dependency Injection implementations in Swift

    How to use dependency injection both manually and with a library like Swinject is valuable because it helps developers understand the core principles behind DI, such as decoupling and testability, before introducing them to more scalable, flexible solutions. By comparing both approaches, you empower readers to make informed architectural decisions based on project complexity and team needs. It appeals to a broad audience—from beginners learning the basics to experienced developers seeking to streamline their code using frameworks—and highlights the real-world tradeoffs between control and convenience, making it a practical and educational resource.

    DIP-Dependency injection principle

    Dependency injection is a software design principle in which an object or function receives the resources or dependencies it needs from external sources rather than creating them itself, promoting loose coupling and greater flexibility in code. By separating the responsibility of constructing dependencies from their usage, dependency injection makes programs easier to test, maintain, and modify, since dependencies can be swapped or mocked without changing the dependent code. This approach is closely related to the inversion of control principle, as it shifts the creation and management of dependencies to an external entity, often called an injector or container, allowing for more modular and configurable systems.

    For our example the interface will be ‘UserService’:

    protocol UserService {
        func fetchUsers() -> [User]
    }
    
    class DefaultUserService: UserService {
        func fetchUsers() -> [User] {
            return [
                User(id: 1, name: "Alice"),
                User(id: 2, name: "Bob")
            ]
        }
    }
    

    This is how View model uses the UserService interface:

    class UserListViewModel: ObservableObject {
        @Published var users: [User] = []
    
        private let userService: UserService
    
        init(userService: UserService) {
            self.userService = userService
            loadUsers()
        }
    
        func loadUsers() {
            self.users = userService.fetchUsers()
        }
    }

    The view that presents user list:

    struct UserListView: View {
        @ObservedObject var viewModel: UserListViewModel
    
        var body: some View {
            List(viewModel.users) { user in
                Text(user.name)
            }
        }
    }

    …but where is dependency injection implemented? You’re probably thinking that right now. Hold on a sec…

    @main
    struct ManualDIApp: App {
        var body: some Scene {
            WindowGroup {
                let userService = DefaultUserService()
                let viewModel = UserListViewModel(userService: userService)
                UserListView(viewModel: viewModel)
            }
        }
    }
    

    At that point in the code, an instance of DefaultUserService—which implements the UserService protocol—is being injected into the viewModel.

    Dependency injection by using Swinject

    Using a dependency injection library like Swinject becomes especially beneficial in larger or more complex iOS applications where managing dependencies manually can become tedious, error-prone, and hard to scale. Libraries automate the resolution and lifecycle of dependencies, support advanced features like scopes, circular dependencies, and conditional bindings, and reduce boilerplate code—making them ideal when your project has many services, view models, or interconnected modules. They also promote consistency and cleaner architecture across teams, especially in projects following patterns like MVVM or Clean Architecture, where dependency graphs can quickly grow intricate.

    First thing to do is add ‘https://github.com/Swinject/Swinject’ as SPM package:

    Look out! Add Swinject to SwinjectDI target, but Swinject-Dynamic to none. I faced compilations issues due to that.

    Using a dependency injection library like Swinject becomes especially beneficial in larger or more complex iOS applications where managing dependencies manually can become tedious, error-prone, and hard to scale. Libraries automate the resolution and lifecycle of dependencies, support advanced features like scopes, circular dependencies, and conditional bindings, and reduce boilerplate code—making them ideal when your project has many services, view models, or interconnected modules. They also promote consistency and cleaner architecture across teams, especially in projects following patterns like MVVM or Clean Architecture, where dependency graphs can quickly grow intricate.

    We have to declare a new component that will be responsible for resolving dependencies:

    import Swinject
    
    class DIContainer {
        static let shared = DIContainer()
        let container: Container
    
        private init() {
            container = Container()
    
            container.register(UserService.self) { _ in DefaultUserService() }
            container.register(UserListViewModel.self) { r in
                UserListViewModel(userService: r.resolve(UserService.self)!)
            }
        }
    }
    

    The code defines a singleton dependency injection container using the Swinject library to manage and resolve dependencies within an iOS application. The DIContainer class initializes a shared Container instance and registers two types: UserService, which is mapped to its concrete implementation DefaultUserService, and UserListViewModel, which depends on UserService and retrieves it from the container using resolution. By centralizing the creation of these objects, the code promotes loose coupling, testability, and maintainability, while Swinject handles the instantiation and dependency resolution automatically.

    import Swinject
    
    class DIContainer {
        static let shared = DIContainer()
        let container: Container
    
        private init() {
            container = Container()
    
            container.register(UserService.self) { _ in DefaultUserService() }
            container.register(UserListViewModel.self) { r in
                UserListViewModel(userService: r.resolve(UserService.self)!)
            }
        }
    }
    

    The code defines a singleton dependency injection container using the Swinject library to manage and resolve dependencies within an iOS application. The DIContainer class initializes a shared Container instance and registers two types: UserService, which is mapped to its concrete implementation DefaultUserService, and UserListViewModel, which depends on UserService and retrieves it from the container using resolution. By centralizing the creation of these objects, the code promotes loose coupling, testability, and maintainability, while Swinject handles the instantiation and dependency resolution automatically.

    @main
    struct SwinjectDIApp: App {
        var body: some Scene {
            WindowGroup {
                let viewModel = DIContainer.shared.container.resolve(UserListViewModel.self)!
                UserListView(viewModel: viewModel)
            }
        }
    }

    To implement dependency injection, we simply call the resolver to fetch the appropriate instance to be injected into UserListView. Notice that UserListViewModel also depends on UserService, but this dependency is also resolved by the DIResolver. In conclusion, we can observe that the lines of code required to construct the dependency stack have been reduced to a single line.

    Handling different Protocol implementations

    What we explained in the previous section covers most cases where dependency injection needs to be implemented. However, what happens when we have different protocol implementations? For example, consider a scenario where the same view is used in different application flows, but the data sources differ—one fetches data from a database, while the other uses a REST API.

    class DefaultUserService: UserService {
        func fetchUsers() -> [User] {
            return [User(id: 1, name: "Alice")]
        }
    }
    
    class DefaultUserServiceV2: UserService {
        func fetchUsers() -> [User] {
            return [User(id: 2, name: "Charlie")]
        }
    }
    

    We now have two classes that implement the UserService protocol. The following changes are required to build the dependency injection stack:

                // Screen 1
                let service1 = DefaultUserService()
                let viewModel1 = UserListViewModel(userService: service1)
    
                // Screen 2
                let service2 = DefaultUserServiceV2()
                let viewModel2 = UserListViewModel(userService: service2)

    View model is the same what it differs is the injected userService.

    Dependency injection by using Swinject

    The dependency injection stack usually consists of the same set of dependencies. This consistency is why third-party libraries like Swinject are beneficial—they take advantage of this common pattern.

    However, occasionally, you may encounter a rare case in your app where the dependency stack for a particular screen needs to be set up differently—for instance, when the data source differs.

    Here’s how the DIContainer resolves dependencies:

    class DIContainer {
        static let shared = DIContainer()
        let container: Container
    
        private init() {
            container = Container()
    
            container.register(UserService.self, name: "v1") { _ in DefaultUserService() }
            container.register(UserService.self, name: "v2") { _ in DefaultUserServiceV2() }
    
            container.register(UserListViewModel.self, name: "v1") { r in
                let service = r.resolve(UserService.self, name: "v1")!
                return UserListViewModel(userService: service)
            }
    
            container.register(UserListViewModel.self, name: "v2") { r in
                let service = r.resolve(UserService.self, name: "v2")!
                return UserListViewModel(userService: service)
            }
        }
    }

    The solution was implemented by registering the instance type with a tag name. When implementing dependency injection, we need to provide an additional name tag parameter.

    @main
    struct SwinjectDIApp: App {
        var body: some Scene {
            WindowGroup {
                let viewModelV1 = DIContainer.shared.container.resolve(UserListViewModel.self, name: "v1")!
                UserListView(viewModel: viewModelV1)
                
    //            let viewModelV2 = DIContainer.shared.container.resolve(UserListViewModel.self, name: "v2")!
    //            UserListView(viewModel: viewModelV2)
            }
        }
    }

    In the previous code chunk, "v1" is hardcoded, but it should be dynamic. Ideally, it should instantiate either viewModelV1 or viewModelV2 depending on the situation.

    Unit tests

    Dependency injection in unit testing typically involves injecting a mock that implements a protocol, allowing for deterministic and controlled responses.

    import Foundation
    @testable import ManualDI
    
    class DefaultUserServiceMock: UserService {
        func fetchUsers() -> [User] {
            return [
                User(id: 99, name: "Mocked User")
            ]
        }
    }

    Unit tests will look something like this:

    import Testing
    @testable import ManualDI
    
    struct ManualDITests {
    
        @Test func example() async throws {
            // Write your test here and use APIs like `#expect(...)` to check expected conditions.
            let mock = DefaultUserServiceMock()
            let viewModel1 = UserListViewModel(userService: mock)
            
            #expect(viewModel1.users.count == 0)
        }
    
    }

    Dependency injection by using Swinject

    Unit test will have to be implemented in following way:

    import Testing
    import Swinject
    @testable import SwinjectDI
    
    struct SwinjectDITests {
    
        @Test func example() async throws {
            // Write your test here and use APIs like `#expect(...)` to check expected conditions.
            let testContainer = Container()
            
            testContainer.register(UserService.self) { _ in DefaultUserServiceMock() }
    
             testContainer.register(UserListViewModel.self) { r in
                 UserListViewModel(userService: r.resolve(UserService.self)!)
             }
    
             let viewModel = testContainer.resolve(UserListViewModel.self)!
            #expect(viewModel.users.first?.name == "Mocked User")
        }
    }

    Never use the app-wide DIContainer.shared in tests — always use a local test container so you can inject mocks safely and independently.

    @Injected properly wrapper

    One more thing… By using following property wrapper:

    import Swinject
    
    @propertyWrapper
    struct Injected<T> {
        private var service: T
    
        init(name: String? = nil) {
            if let name = name {
                self.service = DIContainer.shared.container.resolve(T.self, name: name)!
            } else {
                self.service = DIContainer.shared.container.resolve(T.self)!
            }
        }
    
        var wrappedValue: T {
            service
        }
    }

    DIContiner keeps more simplified:

    class DIContainer {
        static let shared = DIContainer()
        let container: Container
    
        private init() {
            container = Container()
    
            container.register(UserService.self, name: "v1") { _ in DefaultUserService() }
            container.register(UserService.self, name: "v2") { _ in DefaultUserServiceV2() }
    
            container.register(UserListViewModel.self, name: "v1") { _ in UserListViewModel() }
            container.register(UserListViewModel.self, name: "v2") { _ in UserListViewModel() }
        }
    }

    And also viewmodel:

     class UserListViewModel: ObservableObject {
         @Published var users: [User] = []
    
         @Injected(name: "v1") private var userService: UserService
    
         func loadUsers() {
             self.users = userService.fetchUsers()
         }
     }

    Conclusions

    I did not try to convince you how useful Dependency Injection is; you can easily find information about it on the internet. Instead, I aim to show how, with a third-party library like Swinject, the process of setting up the Dependency Injection stack can be simplified.

    You can find source code used for writing this post in following repository

    References

  • S.O.L.I.D. principles in Swift

    S.O.L.I.D. principles in Swift

    Applying SOLID principles to Swift is valuable because it enhances code quality, maintainability, and scalability while leveraging Swift’s unique features like protocols, value types, and strong type safety. Swift’s modern approach to object-oriented and protocol-oriented programming aligns well with SOLID, making it essential for developers aiming to write modular, testable, and flexible code.

    In this post we will revieew 5 principles by examples.

    S.O.L.I.D. principles

    The SOLID principles are a set of five design guidelines that help developers create more maintainable, flexible, and scalable software. These principles were introduced by Robert C. Martin, also known as Uncle Bob, and are widely adopted in object-oriented programming. The acronym SOLID stands for: Single Responsibility Principle (SRP)Open/Closed Principle (OCP)Liskov Substitution Principle (LSP)Interface Segregation Principle (ISP), and Dependency Inversion Principle (DIP). Each principle addresses a specific aspect of software design, such as ensuring that a class has only one reason to change (SRP), allowing systems to be extended without modifying existing code (OCP), ensuring derived classes can substitute their base classes without altering behavior (LSP), creating smaller, more specific interfaces instead of large, general ones (ISP), and depending on abstractions rather than concrete implementations (DIP).

    By adhering to the SOLID principles, developers can reduce code complexity, improve readability, and make systems easier to test, debug, and extend. For example, SRP encourages breaking down large classes into smaller, more focused ones, which simplifies maintenance. OCP promotes designing systems that can evolve over time without requiring extensive rewrites. LSP ensures that inheritance hierarchies are robust and predictable, while ISP prevents classes from being burdened with unnecessary dependencies. Finally, DIP fosters loose coupling, making systems more modular and adaptable to change. Together, these principles provide a strong foundation for writing clean, efficient, and sustainable code.

    SRP-Single responsability principle

    The Single Responsibility Principle (SRP) is one of the SOLID principles of object-oriented design, stating that a class or module should have only one reason to change, meaning it should have only one responsibility or job. This principle emphasizes that each component of a system should focus on a single functionality, making the code easier to understand, maintain, and test. By isolating responsibilities, changes to one part of the system are less likely to affect others, reducing the risk of unintended side effects and improving overall system stability. In essence, SRP promotes modularity and separation of concerns, ensuring that each class or module is cohesive and focused on a specific task.

    // Violating SRP
    class EmployeeNonSRP {
        let name: String
        let position: String
        let salary: Double
        
        init(name: String, position: String, salary: Double) {
            self.name = name
            self.position = position
            self.salary = salary
        }
        
        func calculateTax() -> Double {
            // Tax calculation logic
            return salary * 0.2
        }
        
        func saveToDatabase() {
            // Database saving logic
            print("Saving employee to database")
        }
        
        func generateReport() -> String {
            // Report generation logic
            return "Employee Report for \(name)"
        }
    }
    
    // Adhering to SRP
    class Employee {
        let name: String
        let position: String
        let salary: Double
        
        init(name: String, position: String, salary: Double) {
            self.name = name
            self.position = position
            self.salary = salary
        }
    }
    
    class TaxCalculator {
        func calculateTax(for employee: Employee) -> Double {
            return employee.salary * 0.2
        }
    }
    
    class EmployeeDatabase {
        func save(_ employee: Employee) {
            print("Saving employee to database")
        }
    }
    
    class ReportGenerator {
        func generateReport(for employee: Employee) -> String {
            return "Employee Report for \(employee.name)"
        }
    }

    In the first example, the Employee class violates SRP by handling multiple responsibilities: storing employee data, calculating taxes, saving to a database, and generating reports.

    The second example adheres to SRP by separating these responsibilities into distinct classes. Each class now has a single reason to change, making the code more modular and easier to maintain.

    OCP-Open/Close principle

    The Open/Closed Principle (OCP) is one of the SOLID principles of object-oriented design, stating that software entities (such as classes, modules, and functions) should be open for extension but closed for modification. This means that the behavior of a system should be extendable without altering its existing code, allowing for new features or functionality to be added with minimal risk of introducing bugs or breaking existing functionality. To achieve this, developers often rely on abstractions (e.g., interfaces or abstract classes) and mechanisms like inheritance or polymorphism, enabling them to add new implementations or behaviors without changing the core logic of the system. By adhering to OCP, systems become more flexible, maintainable, and scalable over time.

    protocol Shape {
        func area() -> Double
    }
    
    struct Circle: Shape {
        let radius: Double
        func area() -> Double {
            return 3.14 * radius * radius
        }
    }
    
    struct Square: Shape {
        let side: Double
        func area() -> Double {
            return side * side
        }
    }
    
    // Adding a new shape without modifying existing code
    struct Triangle: Shape {
        let base: Double
        let height: Double
        func area() -> Double {
            return 0.5 * base * height
        }
    }
    
    // Usage
    let shapes: [Shape] = [
        Circle(radius: 5),
        Square(side: 4),
        Triangle(base: 6, height: 3)
    ]
    
    for shape in shapes {
        print("Area: \(shape.area())")
    }

    In this example the Shape protocol defines a contract for all shapes. New shapes like CircleSquare, and Triangle can be added by conforming to the protocol without modifying existing code. This adheres to OCP by ensuring the system is open for extension (new shapes) but closed for modification (existing code remains unchanged).

    LSP-Liksov substitution principle

    The Liskov Substitution Principle (LSP) is one of the SOLID principles of object-oriented design, named after Barbara Liskov. It states that objects of a superclass should be replaceable with objects of a subclass without affecting the correctness of the program. In other words, a subclass should adhere to the contract established by its superclass, ensuring that it can be used interchangeably without causing unexpected behavior or violating the assumptions of the superclass. This principle emphasizes the importance of designing inheritance hierarchies carefully, ensuring that derived classes extend the base class’s functionality without altering its core behavior. Violations of LSP can lead to fragile code, bugs, and difficulties in maintaining and extending the system.

    protocol Vehicle {
        func move()
    }
    
    class Car: Vehicle {
        func move() {
            print("Car is moving")
        }
        
        func honk() {
            print("Honk honk!")
        }
    }
    
    class Bicycle: Vehicle {
        func move() {
            print("Bicycle is moving")
        }
        
        func ringBell() {
            print("Ring ring!")
        }
    }
    
    func startJourney(vehicle: Vehicle) {
        vehicle.move()
    }
    
    let car = Car()
    let bicycle = Bicycle()
    
    startJourney(vehicle: car)      // Output: Car is moving
    startJourney(vehicle: bicycle)  // Output: Bicycle is moving

    In this example, both Car and Bicycle conform to the Vehicle protocol, allowing them to be used interchangeably in the startJourney function without affecting the program’s behavior

    ISP-Interface segregation principle

    The Interface Segregation Principle (ISP) is one of the SOLID principles of object-oriented design, which states that no client should be forced to depend on methods it does not use. In other words, interfaces should be small, specific, and tailored to the needs of the classes that implement them, rather than being large and monolithic. By breaking down large interfaces into smaller, more focused ones, ISP ensures that classes only need to be aware of and implement the methods relevant to their functionality. This reduces unnecessary dependencies, minimizes the impact of changes, and promotes more modular and maintainable code. For example, instead of having a single interface with methods for printing, scanning, and faxing, ISP would suggest separate interfaces for each responsibility, allowing a printer class to implement only the printing-related methods.

    // Violating ISP
    protocol Worker {
        func work()
        func eat()
    }
    
    class Human: Worker {
        func work() {
            print("Human is working")
        }
        func eat() {
            print("Human is eating")
        }
    }
    
    class Robot: Worker {
        func work() {
            print("Robot is working")
        }
        func eat() {
            // Robots don't eat, but forced to implement this method
            fatalError("Robots don't eat!")
        }
    }
    
    // Following ISP
    protocol Workable {
        func work()
    }
    
    protocol Eatable {
        func eat()
    }
    
    class Human: Workable, Eatable {
        func work() {
            print("Human is working")
        }
        func eat() {
            print("Human is eating")
        }
    }
    
    class Robot: Workable {
        func work() {
            print("Robot is working")
        }
    }

    In this example, we first violate ISP by having a single Worker protocol that forces Robot to implement an unnecessary eat() method. Then, we follow ISP by splitting the protocol into Workable and Eatable, allowing Robot to only implement the relevant work() method.

    DIP-Dependency injection principle

    The Dependency Inversion Principle (DIP) is one of the SOLID principles of object-oriented design, which states that high-level modules (e.g., business logic) should not depend on low-level modules (e.g., database access or external services), but both should depend on abstractions (e.g., interfaces or abstract classes). Additionally, abstractions should not depend on details; rather, details should depend on abstractions. This principle promotes loose coupling by ensuring that systems rely on well-defined contracts (interfaces) rather than concrete implementations, making the code more modular, flexible, and easier to test or modify. For example, instead of a high-level class directly instantiating a low-level database class, it would depend on an interface, allowing the database implementation to be swapped or mocked without affecting the high-level logic.

    protocol Engine {
        func start()
    }
    
    class ElectricEngine: Engine {
        func start() {
            print("Electric engine starting")
        }
    }
    
    class Car {
        private let engine: Engine
        
        init(engine: Engine) {
            self.engine = engine
        }
        
        func startCar() {
            engine.start()
        }
    }
    
    let electricEngine = ElectricEngine()
    let car = Car(engine: electricEngine)
    car.startCar() // Output: Electric engine starting

    In this example, the Car class depends on the Engine protocol (abstraction) rather than a concrete implementation, adhering to the DIP

    Conclusions

    We have demonstrated how the SOLID principles align with Swift, so there is no excuse for not applying them. You can find source code used for writing this post in following repository

  • The MVVM-C Blueprint for iOS Apps

    The MVVM-C Blueprint for iOS Apps

    The MVVM-C pattern, which combines the Model-View-ViewModel (MVVM) architecture with a Coordinator layer, offers a structured approach to building scalable and maintainable iOS apps. It effectively separates concerns, making the codebase more modular and easier to test.

    In this tutorial, we will implement a sample focused solely on its navigation components. At the end of the post, you will find the GitHub repository where you can access the sample project used for this tutorial.

    The coordinator component

    In the MVVM-C (Model-View-ViewModel-Coordinator) pattern, the Coordinator is responsible for managing navigation and application flow, ensuring that the View and ViewModel remain focused on UI presentation and business logic, respectively, without being concerned with navigation and flow management. It handles the creation and configuration of View and ViewModel instances, determines which screen to display next based on user actions or app logic, and manages transitions between screens. By centralizing navigation logic, the Coordinator promotes modularity, reusability, and testability, maintaining a clean and scalable architecture.

    Depending on the complexity of the app, the Coordinator can be implemented in different ways:

    • Whole App Coordinator – Best for small apps with a few screens, where a single component can effectively manage the navigation flow.
    • Flow Coordinator – In larger apps, a single coordinator becomes difficult to manage. Grouping screens by business flow improves modularity and maintainability.
    • Screen Coordinator – Each screen has its own dedicated coordinator, making it useful for reusable components, such as a payment screen appearing in different user journeys. This approach is often used in architectures like VIPER, where each module operates independently.

    Ultimately, the choice of implementation depends on the app’s complexity and business requirements; no single pattern fits all use cases.

    The sample app

    The app we are going to implement is a Tab View app. Each tab represents a different navigation flow:

    flowtab1
    Screenshot

    The First Tab Flow is a flow coordinator that presents a Primary View with two buttons. These buttons navigate to either Secondary View 1 or Secondary View 2.

    • When Secondary View 2 appears, it includes a button that allows navigation to Tertiary View 1.
    • In Tertiary View 1, there is a button that returns directly to the Primary View or allows navigation back to the previous screen using the back button.
    • Secondary View 2 does not lead to any additional views; users can only return to the previous screen using the back button.

    The Second Tab Flow is managed by a Screen Coordinator, which presents a single screen with a button that opens a view model.

    • In this context, we consider the modal to be part of the view.
    • However, depending on the app’s design, the modal might instead be managed by the coordinator.

    Main Tab View

    This is the entry view point from the app:

    struct MainView: View {
        @StateObject private var tab1Coordinator = Tab1Coordinator()
        @StateObject private var tab2Coordinator = Tab2Coordinator()
    
        var body: some View {
            TabView {
                NavigationStack(path: $tab1Coordinator.path) {
                    tab1Coordinator.build(page: .primary)
                        .navigationDestination(for: Tab1Page.self) { page in
                            tab1Coordinator.build(page: page)
                        }
                }
                .tabItem {
                    Label("Tab 1", systemImage: "1.circle")
                }
    
                NavigationStack(path: $tab2Coordinator.path) {
                    tab2Coordinator.build(page: .primary)
                        .navigationDestination(for: Tab2Page.self) { page in
                            tab2Coordinator.build(page: page)
                        }
                }
                .tabItem {
                    Label("Tab 2", systemImage: "2.circle")
                }
            }
        }
    }

    The provided SwiftUI code defines a MainView with a TabView containing two tabs, each managed by its own coordinator (Tab1Coordinator and Tab2Coordinator). Each tab uses a NavigationStack bound to the coordinator’s path property to handle navigation. The coordinator’s build(page:) method constructs the appropriate views for both the root (.primary) and subsequent pages.

    The navigationDestination(for:) modifier ensures dynamic view creation based on the navigation stack, while the tabItem modifier sets the label and icon for each tab. This structure effectively decouples navigation logic from the view hierarchy, promoting modularity and ease of maintenance.

    Another key aspect is selecting an appropriate folder structure. The one I have chosen is as follows:

    Screenshot

    This may not be the best method, but it follows a protocol to prevent getting lost when searching for files.

    The flow coordinator

    The first structure we need to create is an enum that defines the screens included in the flow:

    enum Tab1Page: Hashable {
        case primary
        case secondary1
        case secondary2
        case tertiary
    }

    Hashable is not free; we need to push and pop those cases into a NavigationPath. The body of the coordinator is as follows:

    class Tab1Coordinator: ObservableObject {
        @Published var path = NavigationPath()
    
        func push(_ page: Tab1Page) {
            path.append(page)
        }
    
        func pop() {
            path.removeLast()
        }
    
        func popToRoot() {
            path.removeLast(path.count)
        }
    
        @ViewBuilder
           func build(page: Tab1Page) -> some View {
               switch page {
               case .primary:
                   Tab1PrimaryView(coordinator: self)
               case .secondary1:
                   Tab1SecondaryView1(coordinator: self)
               case .secondary2:
                   Tab1SecondaryView2()
               case .tertiary:
                   Tab1TertiaryView(coordinator: self)
               }
           }
    }

    The Tab1Coordinator class is an ObservableObject that manages navigation within a SwiftUI view hierarchy for a specific tab (Tab1). It uses a NavigationPath to track the navigation stack, allowing views to be pushed onto or popped from the stack through methods such as push(_:), pop(), and popToRoot(). The @Published property path ensures that any changes to the navigation stack are automatically reflected in the UI.

    The build(page:) method, marked with @ViewBuilder, constructs and returns the appropriate SwiftUI view (e.g., Tab1PrimaryView, Tab1SecondaryView1, Tab1SecondaryView2, or Tab1TertiaryView) based on the provided Tab1Page enum case. This approach enables dynamic navigation between views while maintaining a clean separation of concerns.

    The last section of the coordinator is the protocol implementation for the views presented by the coordinator. When a view has completed its work, it delegates the decision of which screen to present next to the coordinator. The coordinator is responsible for managing the navigation logic, not the view.

    extension Tab1Coordinator: Tab1PrimaryViewProtocol {
        func goToSecondary1() {
            push(.secondary1)
        }
        func goToSecondary2() {
            push(.secondary2)
        }
    }
    
    extension Tab1Coordinator: Tab1SecondaryView1Protocol {
        func goToTertiaryView() {
            push(.tertiary)
        }
    }
    
    extension Tab1Coordinator: Tab1TertiaryViewProtocol {
        func backToRoot() {
            self.popToRoot()
        }
    }
    

    This is the code from one of the views:

    import SwiftUI
    
    protocol Tab1PrimaryViewProtocol: AnyObject {
        func goToSecondary1()
        func goToSecondary2()
    }
    
    struct Tab1PrimaryView: View {
         let coordinator: Tab1PrimaryViewProtocol
        
            var body: some View {
                
                VStack {
                    Button("Go to Secondary 1") {
                        coordinator.goToSecondary1()
                    }
                    .padding()
    
                    Button("Go to Secondary 2") {
                        coordinator.goToSecondary2()
                    }
                    .padding()
                }
                .navigationTitle("Primary View")
            }
    }

    When the view doesn’t know how to proceed, it should call its delegate (the Coordinator) to continue.

    The screen coordinator

    The first structure we need to create is an enum that defines the screens in the flow:

    enum Tab2Page: Hashable {
        case primary
    }
    
    class Tab2Coordinator: ObservableObject {
        @Published var path = NavigationPath()
        
        @ViewBuilder
        func build(page: Tab2Page) -> some View {
            switch page {
            case .primary:
                Tab2PrimaryView(coordinator: self)
            }
        }
    }

    Hashable is not free; we need to push/pop these cases into a NavigationPath. The body of the coordinator is simply as follows:

    class Tab1Coordinator: ObservableObject {
        @Published var path = NavigationPath()
    
        func push(_ page: Tab1Page) {
            path.append(page)
        }
    
        func pop() {
            path.removeLast()
        }
    
        func popToRoot() {
            path.removeLast(path.count)
        }
    
        @ViewBuilder
           func build(page: Tab1Page) -> some View {
               switch page {
               case .primary:
                   Tab1PrimaryView(coordinator: self)
               case .secondary1:
                   Tab1SecondaryView1(coordinator: self)
               case .secondary2:
                   Tab1SecondaryView2()
               case .tertiary:
                   Tab1TertiaryView(coordinator: self)
               }
           }
    }

    The provided code defines a SwiftUI-based navigation structure for a tabbed interface. The Tab2Coordinator class is an ObservableObject that manages the navigation state using a NavigationPath, which is a state container for navigation in SwiftUI. The @Published property path allows the view to observe and react to changes in the navigation stack. The build(page:) method is a ViewBuilder that constructs the appropriate view based on the Tab2Page enum case. Specifically, when the page is .primary, it creates and returns a Tab2PrimaryView, passing the coordinator itself as a dependency.

    This approach is commonly used in SwiftUI apps to handle navigation between different views within a tab, promoting a clean separation of concerns and state management. The Tab2Page enum is marked as Hashable, which is required for it to work with NavigationPath.

    Conclusions

    Coordinator is a key component that allows to unload ViewModel or ViewModel logic for controlling navigation logic. I hope this post will help you to understand better this pattern.

    You can find the source code used for this post in the repository linked below.

    References

  • Dip your toes in middle of TCA ocean

    Dip your toes in middle of TCA ocean

    TCA, or The Composable Architecture, is a framework for iOS development that provides a structured and scalable approach to building robust, maintainable applications. Created by Brandon Williams and Stephen Celis, TCA leverages functional programming principles and Swift’s powerful type system to offer a modern solution for iOS app architecture.

    In this post, we’ll explore how to migrate our Rick and Morty iOS app to TC

    The architecture

    TCA consists of five main components:

    1. State: A single type that represents the entire state of an app or feature.
    2. Actions: An enumeration of all possible events that can occur in the app.
    3. Environment: A type that wraps all dependencies of the app or feature.
    4. Reducer: A function that transforms the current state to the next state based on a given action.
    5. Store: The runtime that powers the feature and manages the state.

    TCA offers several advantages for iOS development:

    • Unidirectional data flow: This makes it easy to understand how changes in state occur, simplifying debugging and preventing unexpected side effects.
    • Improved testability: TCA encourages writing features that are testable by default.
    • Modularity: It allows for composing separate features, enabling developers to plan, build, and test each part of the app independently.
    • Scalability: TCA is particularly useful for complex applications with many states and interactions.

    Configure XCode project

    To get started with this architecture, integrate the ComposableArchitecture library from its GitHub repository.

    Downloading might take some time.

    Character feature

    The component that we have to change implentation is basically the ViewModel component. In this case will be renamed as CharacterFeature.

    import ComposableArchitecture
    
    @Reducer
    struct CharatersFeature {
        @ObservableState
        struct State: Equatable {
            var characters: [Character] = []
            var isLoading: Bool = false
        }
    
        enum Action {
            case fetchCharacters
            case fetchCharactersSuccess([Character])
        }
    
        var body: some ReducerOf<Self> {
            Reduce { state, action in
                switch action {
                case .fetchCharacters:
                    state.isLoading = true
                    state.characters = []
                    return .run { send in
                        let result = await currentApp.dataManager.fetchCharacters(CharacterService())
                        switch result {
                        case .success(let characters):
                            //state.characters = characters
                            await send(.fetchCharactersSuccess(characters))
                        case .failure(let error):
                            print(error)
                        }
                    }
                case .fetchCharactersSuccess(let characters):
                    state.isLoading = false
                    state.characters = characters
                    return .none
                }
            }
        }
    }

    This code defines a feature using the Composable Architecture (TCA) framework in Swift. Let’s break down what this code does:

    1. Import and Structure:
      • It imports the ComposableArchitecture framework.
      • It defines a CharatersFeature struct with the @Reducer attribute, indicating it’s a reducer in the TCA pattern.
    2. State:
      • The State struct is marked with @ObservableState, making it observable for SwiftUI views.
      • It contains two properties:
        • characters: An array of Character objects.
        • isLoading: A boolean to track if data is being loaded.
    3. Actions:
      • The Action enum defines two possible actions:
        • fetchCharacters: Triggers the character fetching process.
        • fetchCharactersSuccess: Handles successful character fetching.
    4. Reducer:
      • The body property defines the reducer logic.
      • It uses a Reduce closure to handle state changes based on actions.
    5. Action Handling:
      • For .fetchCharacters:
        • Sets isLoading to true and clears the characters array.
        • Runs an asynchronous operation to fetch characters.
        • On success, it dispatches a .fetchCharactersSuccess action.
        • On failure, it prints the error.
      • For .fetchCharactersSuccess:
        • Sets isLoading to false.
        • Updates the characters array with the fetched data.
    6. Asynchronous Operations:
      • It uses .run for handling asynchronous operations within the reducer.
      • The character fetching is done using currentApp.dataManager.fetchCharacters(CharacterService()).

    This code essentially sets up a state management system for fetching and storing character data, with loading state handling. It’s designed to work with SwiftUI and the Composable Architecture, providing a structured way to manage application state and side effects.

    View

    The view is almost the same as before:

    struct CharacterView: View {
        let store: StoreOf<CharatersFeature>
        
        var body: some View {
            NavigationView {
                ZStack {
                    if store.isLoading {
                        ProgressView()
                    }
                ScrollView {
                        ForEach(store.characters) { character in
                            NavigationLink {
                                DetailView(character: character)
                            } label: {
                                HStack {
                                    characterImageView(character.imageUrl)
                                    Text("\(character.name)")
                                    Spacer()
                                }
                            }
                        }
                    }
                }
            }
            .padding()
            .onAppear {
                store.send(.fetchCharacters)
            }
        }

    The store holds observable items used by the view to present either the progression view or the character list. When the view appears, it triggers the .fetchCharacters action, prompting the reducer to fetch the character list.

    Unit test

    Unit testing with TCA differs significantly from my expectations:

        @Test func example() async throws {
            // Write your test here and use APIs like `#expect(...)` to check expected conditions.
            let store = await TestStore(initialState: CharatersFeature.State()) {
                CharatersFeature()
            }
            
            await store.send(.fetchCharacters) {
              $0.isLoading = true
            }
            
            await store.receive(\.fetchCharactersSuccess, timeout: .seconds(1)) {
              $0.isLoading = false
                $0.characters = expCharacters
            }
            
            await store.finish()
        }

    In TCA, testing often focuses on asserting the state transitions and effects of the reducer. Instead of traditional XCTest assertions like XCTAssertEqual, TCA provides its own mechanism for testing reducers using TestStore, which is a utility designed to test state changes, actions, and effects in a deterministic way.

    Conclusions

    This is a very minimalistic example just to get in touch with this architecture. With more complex applications, I meain with some flows and many screens reducer would become a huge chunk of code, so god approach would be implement this pattern per app flow.You can find source code used for writing this post in following repository.

  • Safely migrating persisted models in iOS to prevent crashes

    Safely migrating persisted models in iOS to prevent crashes

    Most mobile native apps (iOS/Android) support evolving businesses, and must be ready to accommodate changes. Evolving persisted data in an app is crucial for maintaining a good user experience and preventing obsolescence as business requirements change. In my personal experience with production crashes, this is one of the most common issues encountered after a production release. While removing and reinstalling the app often fixes the issue, this is not an ideal solution.

    The aim of this post is to demonstrate a possible method for handling migration in non-database persisted data. We will explain how to migrate JSON stored in user defaults, though this approach could also apply to data stored in files or keychain. It’s important to note that we’re focusing on non-database storage because database frameworks typically provide their own migration mechanisms that should be followed.

    The starting point

    The base application used as the foundation for this post simply displays a button. When pressed, it saves a structure to UserDefaults. Upon restarting the app after it has been closed, the previously stored content is retrieved from UserDefaults.

    Let me introduce the persisted structure. It has been simplified for better understanding, but is not what you would find in a real production app:

    struct Person: Codable {
        let name: String
        let age: String
    }

    New requirements

    The business has decided that an email address is also required. Therefore, we will proceed to implement this feature.

    struct Person: Codable {
        let name: String
        let age: String
        let email: String
    }
    Build and run, but suddenly something unexpected happens…

    Initially, the app stored the data in the format {name, age}. However, it has since been updated to expect the format {name, age, email}. This discrepancy means that previously persisted data cannot be decoded properly, leading to an exception being thrown.

    A common mistake during development at this stage is to simply remove the app from the simulator, reinstall the new version, and move on, effectively ignoring the issue. This is a poor decision, as it fails to address the root problem. Eventually, this oversight will come back to haunt you when the same issue occurs on hundreds, thousands, or an unacceptably large number of real app installations. This will result in a very poor user experience.

    The first step to properly address this issue is to add a version field to the data structure. This allows for better handling of future changes to the structure.

    struct Person: Codable {
        var version: Int {
            return 1
        }
        let name: String
        let age: String
        let email: String
    }

    We will implement a Migration Manager responsible for handling migrations

    @MainActor
    protocol MigrationManagerProtocol {
        func applyMigration()
    }
    
    @MainActor
    final class MigrationManager: ObservableObject {
        @Published var migrationPenging = true
        @Published var migrationFailed = false
        
        struct PersonV0: Codable {
            let name: String
            let age: String
        }
        
        typealias PersonV1 = Person
    }

    In this class, we will include a copy of the original Person structure, referred to as PersonV0. The current Person structure is named PersonV1

    extension MigrationManager: MigrationManagerProtocol {
        func applyMigration() {
            defer { migrationPenging = false }
            applyPersonMigration()
        }
        
        private func isPersonMigrationPending() -> Bool {
            let userDefaultsManager = appSingletons.userDefaultsManager
            return userDefaultsManager.get(Person.self, forKey: UserDefaultsManager.key.person) == nil
        }
        
        private func applyPersonMigration() {
            let userDefaultsManager = appSingletons.userDefaultsManager
    
            guard isPersonMigrationPending() else {
                return // No migration needed
            }
            let currentStoredPersonVersion = storedPersonVersion()
            if currentStoredPersonVersion == 0,
                let personV0 = userDefaultsManager.get(PersonV0.self, forKey: UserDefaultsManager.key.person) {
                let person = PersonV1(name: personV0.name, age: personV0.age, email: "---")
                saveInUserDefaults(person, UserDefaultsManager.key.person, &migrationFailed)
            }
        }
        
        private func storedPersonVersion() -> Int {
                return 0
        }
    ...
    }

    The migration process begins by determining the version of the Person structure stored using the storedPersonVersion() function. At this stage in the application’s evolution, the version is 0.

    If the current stored version is 0, the process involves fetching PersonV0 from UserDefaults and performing a migration. This migration entails transforming PersonV0 into PersonV1 by adding an email field with a default value.

    struct JSONPersistedMigrationApp: App {
        var body: some Scene {
            WindowGroup {
                ContentView()
                    .onAppear {
    #if DEBUG
                        setupDebugSwift()
    #endif
                    }
                    .onShake {
    #if DEBUG
                        DebugSwift.show()
    #endif
                    }
                    .task {
                        appSingletons.migrationManager.applyMigration()
                    }
            }
        }

    Finally, we call migrationManager.applyMigration() within the .task modifier to ensure it executes only once during the app’s startup.

    For debugging purposes, I found the DebugSwift tool very useful. I explain in more detail how to integrate this tool and its main features in the following post.

    Now, build and run the app:

    Migration is currently being executed, and the app is displaying data properly.

    When we open the DebugView tool to review user defaults, we observe that the migration has been completed exactly as expected.

    Did we really finish? Well, not yet. It is mandatory to implement a test case that ensures the Person object can be migrated from V0 to V1.

    Updating an attribute

    Once the infrastructure for running migrations is ready, the next changes to the persisted structure should involve either a Tic-Tac-Toe game or an easy recipe.

    Now, the business has requested that we rename the «name» field to «alias.» The first step is to update the structure by increasing the version number and renaming the field.

    struct Person: Codable {
        var version: Int {
            return 2
        }
        
        let alias: String
        let age: String
        let email: String
    }
    

    Second, add PersonV1 to the Migration Manager and set PersonV2 as the current Person structure.

    @MainActor
    final class MigrationManager: ObservableObject {
        @Published var migrationPenging = true
        @Published var migrationFailed = false
        
        struct PersonV0: Codable {
            let name: String
            let age: String
        }
        
        struct PersonV1: Codable {
            var version: Int {
                return 1
            }
            let name: String
            let age: String
            let email: String
        }
        
        typealias PersonV2 = Person
    }

    The third step is to update storedPersonVersion(). This time, the stored Person version could be either V0 or V1.

        private func storedPersonVersion() -> Int {
            let userDefaultsManager = appSingletons.userDefaultsManager
            if let personV1 = userDefaultsManager.get(PersonV1.self, forKey: UserDefaultsManager.key.person) {
                return 1
            } else {
                return 0
            }
        }

    The fourth step is to implement the migration-if-block inside applyPersonMigration.

        private func applyPersonMigration() {
            let userDefaultsManager = appSingletons.userDefaultsManager
    
            guard isPersonMigrationPending() else {
                return // No migration needed
            }
            let currentStoredPersonVersion = storedPersonVersion()
            if currentStoredPersonVersion == 0,
                let personV0 = userDefaultsManager.get(PersonV0.self, forKey: UserDefaultsManager.key.person) {
                let person = PersonV1(name: personV0.name, age: personV0.age, email: "---")
                saveInUserDefaults(person, UserDefaultsManager.key.person, &migrationFailed)
            }
            if currentStoredPersonVersion <= 1,
                let personV1 = userDefaultsManager.get(PersonV1.self, forKey: UserDefaultsManager.key.person) {
                let person = PersonV2(alias: personV1.name, age: personV1.age, email: personV1.email)
                saveInUserDefaults(person, UserDefaultsManager.key.person, &migrationFailed)
            }
        }

    It has to be an independent if-block,. In case the app were V0 both if-blocks would be executed, and in case were V1 the tha last block woud be executed.

    Fifth and last step, do unit test, now appears a new test case. One for testing migration from V0 to V2 and another for V1 to V2:

    Remember, five steps one after the other.

    Removing an attribute

    Now, we will remove the age attribute. One, update the person structure.

    struct Person: Codable {
        var version: Int {
            return 3
        }
        
        let alias: String
        let email: String
    }

    Two, add PersonV2 to MigrationManager and set PersonV3 as the current Person structure.

    @MainActor
    final class MigrationManager: ObservableObject {
        @Published var migrationPenging = true
        @Published var migrationFailed = false
        
        struct PersonV0: Codable {
            let name: String
            let age: String
        }
        
        struct PersonV1: Codable {
            var version: Int {
                return 1
            }
            
            let name: String
            let age: String
            let email: String
        }
        
        struct PersonV2: Codable {
            var version: Int {
                return 2
            }
            
            let alias: String
            let age: String
            let email: String
        }
        
        typealias PersonV3 = Person
    }

    Three, update storedPersonVersion(). This time, the stored Person version could be V0, V1, or V2:

        private func storedPersonVersion() -> Int {
            let userDefaultsManager = appSingletons.userDefaultsManager
            if let _ = userDefaultsManager.get(PersonV2.self, forKey: UserDefaultsManager.key.person) {
                return 2
            } else if let _ = userDefaultsManager.get(PersonV1.self, forKey: UserDefaultsManager.key.person) {
                return 1
            } else {
                return 0
            }
        }

    Four, the migration-if-block inside applyPersonMigration:

    private func applyPersonMigration() {
            let userDefaultsManager = appSingletons.userDefaultsManager
    
            guard isPersonMigrationPending() else {
                return // No migration needed
            }
            let currentStoredPersonVersion = storedPersonVersion()
            if currentStoredPersonVersion == 0,
                let personV0 = userDefaultsManager.get(PersonV0.self, forKey: UserDefaultsManager.key.person) {
                let person = PersonV1(name: personV0.name, age: personV0.age, email: "---")
                saveInUserDefaults(person, UserDefaultsManager.key.person, &migrationFailed)
            }
            if currentStoredPersonVersion <= 1,
                let personV1 = userDefaultsManager.get(PersonV1.self, forKey: UserDefaultsManager.key.person) {
                let person = PersonV2(alias: personV1.name, age: personV1.age, email: personV1.email)
                saveInUserDefaults(person, UserDefaultsManager.key.person, &migrationFailed)
            }
            if currentStoredPersonVersion <= 2,
                let personV2 = userDefaultsManager.get(PersonV2.self, forKey: UserDefaultsManager.key.person) {
                let person = PersonV3(alias: personV2.alias, email: personV2.email)
                saveInUserDefaults(person, UserDefaultsManager.key.person, &migrationFailed)
            }
        }

    Five: Perform unit tests. A new test case has now been added, which tests migration from V0 to V3, V1 to V3, and V2 to V3.

    Conclusions

    In this post, you learned how to migrate non-database persisted data in your app. You can find the base project for developing this post in this repository.

  • iOS start up sequencer pattern

    iOS start up sequencer pattern

    In mobile native apps (iOS/Android), it is quite common to execute a series of tasks before the app is ready for the user. These tasks might include checking if the app requires an update, fetching remote app configurations, presenting the «What’s New» information for the latest release, and requesting user login if the user is not already logged in. All of this needs to be done as quickly as possible, often with animations playing to keep the user engaged during the wait.

    This post introduces what I call the sequencer pattern. By leveraging NSOperation, we can encapsulate each task into a self-contained unit and define dependencies among them. This approach establishes the initial execution order of the tasks. An added advantage is that when two or more tasks have no dependencies, iOS can execute them in parallel, further reducing startup times.

    Adding splash screen

    The first task we will add is responsible for presenting the splash screen. First, we will modify the ContentView.

    struct ContentView: View {
        @StateObject var sequencer = appSingletons.sequencer
        var body: some View {
            if sequencer.isDone {
                HomeView()
            } else {
                sequencer.currentView
            }
        }    
    }

    Sequencer at the end is another singleton, but gathered in a global structure. I explain the benefits of this aproach in the post Safely gathering singletons while avoiding data races. And then basically while sqeuencer has not finished (!sequencer.isDone) is the responsible for providing view depending on task executed. When is done then is delegated whole view hierarchy to HomeView.

    Let’s see what is on Sequencer:

    final class Sequencer: ObservableObject {
        @MainActor
        @Published var isDone: Bool = false
    
        @MainActor
        @Published var currentView: AnyView = AnyView(Text("Initial View"))
    
        @MainActor
        func updateView(to newView: AnyView) {
            currentView = newView
        }
    
        @MainActor
        static let shared = Sequencer()
    
        fileprivate let operationQueue = OperationQueue()
    
        private init() { }
    
        @MainActor
        func start() {
            Task {
                await self.regularInitialSequence()
            }
        }
    
        @GlobalManager
        func regularInitialSequence() {
            let presentSplashOperation = PresentSplashOperation()
            let operations = [presentSplashOperation]
            
            // Add operation dependencies
    
            operationQueue.addOperations(operations, waitUntilFinished: false)
        }
    
        func cancel() {
            operationQueue.cancelAllOperations()
        }
    }

    The Sequencer is an ObservableObject that publishes the current view associated with any task, as well as its readiness status. The start method creates tasks and initiates their execution. Currently, only the splash view task is being executed.

    The PresentSplashOperation performs the following functions:

        override func main() {
            os_log("Start: PresentSplashOperation", log: log, type: .debug)
            Task { @MainActor in
                Sequencer.shared.updateView(to: AnyView(SequencerView()))
            }
            sleep(5)
            os_log("End: PresentSplashOperation", log: log, type: .debug)
            self.state = .Finished
            Task { @MainActor in
                Sequencer.shared.isDone = true
            }
        }

    Provides the view to be displayed while the PresentingSplashOperation is being executed. Afterward, there is a delay of 5 seconds before marking the task as finished. Once completed:

    1. isDone is set to true, allowing view control to transition to ContentView and present HomeView.
    2. self.state is set to .Finish, enabling the NSOperations engine to execute the next task, if another operation depends on this one to start.

    To initiate the process, simply call the start method from the sequencer to begin the sequence.

    @main
    struct SequencerPatternApp: App {
        var body: some Scene {
            WindowGroup {
                ContentView()
                    .task {
                    appSingletons.sequencer.start()
                }
            }
        }
    }

    Build and run on a simulator or real device, and the result should be:

    What’s new screen

    It is quite common that whenever there is an app software update introducing new user features, a page viewer is displayed once to showcase what’s new in the app. First of all, let’s set the app version in a centralized location, as explained in Force update iOS Apps when… post:

    Then we are going to implement a task that carries on this task:

    @GlobalManager
    final class WhatsNewOperation: ConcurrentOperation, @unchecked Sendable {
        
        
        override init() {
            super.init()
        }
    
        @MainActor
        func WhatsNewView() -> some View {
            VStack {
                HStack {
                    Spacer()
                    Button {
                        Sequencer.shared.isDone = true
                        self.state = .Finished
                    } label: {
                        Image(systemName: "xmark")
                            .font(.system(size: 20, weight: .bold))
                            .foregroundColor(.white)
                            .frame(width: 40, height: 40)
                            .background(Color.red)
                            .clipShape(Circle())
                            .shadow(radius: 5)
                    }
                }
               // Spacer()
                TabView{
                    VStack {
                        Text("What's new feature A")
                    }
                    VStack {
                        Text("What's new feature B")
                    }
                    VStack {
                        Text("What's new feature C")
                    }
                }
                .font(.system(size: 20, weight: .bold))
                .tabViewStyle(.page)
                .indexViewStyle(.page(backgroundDisplayMode: .always))
            }
            .padding()
        }
        
        override func main() {
            @AppStorage("appVersion") var appVersion = "0.0.0"
            
            os_log("Start: WhatsNewOperation", log: log, type: .debug)
            let marketingVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? ""
            let isLatest = appVersion == marketingVersion
            if !isLatest {
                appVersion = marketingVersion
                Task { @MainActor in
                    Sequencer.shared.updateView(to: AnyView(WhatsNewView()))
                }
            } else {
                self.state = .Finished
                Task { @MainActor in
                    Sequencer.shared.isDone = true
                }
            }
            os_log("End: WhatsNewOperation", log: log, type: .debug)
        }
    }
     

    We fetch the appVersion from the App Store (via UserDefaults, as implemented previously). This is compared against the current app version stored in the project configuration (MARKETING_VERSION). If the versions differ, a «What’s New» view is presented. After this, the new MARKETING_VERSION value is stored in AppStorage.

    An important note: the last task in the sequencer is now WhatsNewOperation. As a result, this operation is responsible for setting Sequencer.shared.isDone to true. PresentSplashOperation is no longer responsible for setting this flag. Be sure to remove any code in PresentSplashOperation that sets this flag; otherwise, HomeView will be presented as soon as PresentSplashOperation finishes.

        override func main() {
            os_log("Start: PresentSplashOperation", log: log, type: .debug)
            Task { @MainActor in
                Sequencer.shared.updateView(to: AnyView(SequencerView()))
            }
            sleep(5)
            os_log("End: PresentSplashOperation", log: log, type: .debug)
            self.state = .Finished
    //        Task { @MainActor in
    //            Sequencer.shared.isDone = true
    //        }
        }

    Look out! The self.state = .Finished remains untouched. Now, this will allow the NSOperation engine to process the next operation (PresentSplashOperation). It is now time to create a new operation, set its dependencies, and update the regularInitialSequence() method.

        @GlobalManager
        func regularInitialSequence() {
            let presentSplashOperation = PresentSplashOperation()
            let whatsNewOperation = WhatsNewOperation()
            
            // DO NOT FORGET ADD OPERATION IN operations array. XDDDDD
            let operations = [presentSplashOperation,
                              whatsNewOperation]
            
            // Add operation dependencies
            whatsNewOperation.addDependency(presentSplashOperation)
            
            operationQueue.addOperations(operations, waitUntilFinished: false)
        }

    Add a new WhatsNewOperation() to the operations array. It’s important to set the whatsNewOperation to depend on the completion of the presentSplashOperation.

    Build and run. The expected result should be:

    The ‘What’s New’ section is displayed only once upon the app’s first startup and does not appear on subsequent launches.

    Force update

    We are now going to insert a force update operation between the previous steps. Specifically, the sequence will be: PresentSplash, followed by ForceUpdateOperation, and then What’s New.

    The implementation of the force update operation is as follows:

    @GlobalManager
    final class ForceUpdateOperation: ConcurrentOperation, @unchecked Sendable {
    
        override init() {
            super.init()
        }
    
        @MainActor
        func ForceUpdateRequiredView() -> some View {
            VStack {
              //  ProgressView()
                Text("Software Update Required!!!")
                    .font(.largeTitle)
                    .fontWeight(.bold)
                Button("Download it from Apple Store...") {}
                    .buttonStyle(.borderedProminent)
            }
                .padding()
        }
    
        override func main() {
            let required = true
            os_log("Start: ForceUpdateOperation", log: log, type: .debug)
            sleep(5)
            if required {
                Task { @MainActor in
                    Sequencer.shared.updateView(to: AnyView(ForceUpdateRequiredView()))
                }
            } else {
                self.state = .Finished
            }
            os_log("End: ForceUpdateOperation", log: log, type: .debug)
        }
    }

    To emulate behavior, we initially included a 5-second sleep. Using a hardcoded flag, we determined whether a force update was required.

    Now that we have a long-running operation, we can remove the sleep delay in the PresentSplash operation.

        override func main() {
            os_log("Start: PresentSplashOperation", log: log, type: .debug)
            Task { @MainActor in
                Sequencer.shared.updateView(to: AnyView(SequencerView()))
            }
    //        sleep(5)
            os_log("End: PresentSplashOperation", log: log, type: .debug)
            self.state = .Finished
    //        Task { @MainActor in
    //            Sequencer.shared.isDone = true
    //        }
        }
    }

    It’s time to reorganize operations. Open the Sequencer and update the regularInitialSequence method.

        @GlobalManager
        func regularInitialSequence() {
            let presentSplashOperation = PresentSplashOperation()
            let forceUpdateOperation = ForceUpdateOperation()
            let whatsNewOperation = WhatsNewOperation()
            
            // DO NOT FORGET ADD OPERATION IN operations array. XDDDDD
            let operations = [presentSplashOperation,
                              forceUpdateOperation,
                              whatsNewOperation]
            
            // Add operation dependencies
            forceUpdateOperation.addDependency(presentSplashOperation)
            whatsNewOperation.addDependency(forceUpdateOperation)
            
            operationQueue.addOperations(operations, waitUntilFinished: false)
        }

    Simply add a new ForceUpdateOperation to the operations array and reorganize the operation dependencies. The WhatsNewOperation should depend on the ForceUpdateOperation, and the ForceUpdateOperation should depend on the PresentSplashOperation.

    After making these changes, build and run the application.

    I will now set the required flag in the ForceUpdateOperation to false, so it doesn’t block the app startup sequence. We will then review the logs to assess the execution sequence of the operations.

    Fetch configuration

    Up until now, we have been sequencing operations, but sometimes it is possible to parallelize operations to reduce startup sequence time. In this case, we have created a simulated FetchConfiguration operation:

    @GlobalManager
    final class FetchConfigurationOperation: ConcurrentOperation, @unchecked Sendable {
    
        override init() {
            super.init()
        }
    
        override func main() {
            let required = true
            os_log("Start: FetchConfigurationOperation", log: log, type: .debug)
            sleep(8)
            self.state = .Finished
            os_log("End: FetchConfigurationOperation", log: log, type: .debug)
        }
    }

    To emulate the behavior, we initially included an 8-second sleep. This operation will be executed in parallel with the Force Update operation. Let’s create the operation and add its dependencies.

        @GlobalManager
        func regularInitialSequence() {
            let presentSplashOperation = PresentSplashOperation()
            let forceUpdateOperation = ForceUpdateOperation()
            let fetchConfigurationOperation = FetchConfigurationOperation()
            let whatsNewOperation = WhatsNewOperation()
            let operations = [presentSplashOperation,
                              forceUpdateOperation,
                              fetchConfigurationOperation,
                              whatsNewOperation]
            
            // Add operation dependencies
            forceUpdateOperation.addDependency(presentSplashOperation)
            
            fetchConfigurationOperation.addDependency(presentSplashOperation)
            
            whatsNewOperation.addDependency(forceUpdateOperation)
            whatsNewOperation.addDependency(fetchConfigurationOperation)
    
            operationQueue.addOperations(operations, waitUntilFinished: false)
        }

    Create a FetchConfigurationOperation and add it to the operations array. Ensure that it has the same dependencies as the ForceUpdateConfiguration operation. Additionally, the WhatsNewOperation must also depend on FetchConfiguration.

    Build the project, run the operation, and review the logs.

    ForceUpdateOperation and FetchConfiguration start simultaneously, and it is only when FetchConfiguration finishes that the WhatsNewOperation is executed.

    Conclusions

    I have used this pattern in two real production iOS projects with reasonable success, and I encourage you to try it out in your personal projects. You can find the codebase used to write this post in this repository.