Github.com/agelessman/…

Those of you who have not written a complete SwiftUI project should not have used Combine very much. It can be said that Combine is a special tool for processing data. ** If you learn these knowledge, your efficiency in writing SwiftUI programs will definitely increase exponentially.

Many articles have been written before about Publisher, Operator and Subscriber in Combine in detail. I believe that you have a basic understanding of Combine. Today, I will lead you to study the practical application of Combine.

You can find the collection of SwiiftUI and Combine here: FuckingSwiftUI

CombineDemoTest CombineDemoTest

Simulated Web search

The figure above illustrates one of the most common scenarios in development, where a search is performed based on user input in real time. Such a feature looks very simple on the surface, but the internal logic is quite detailed:

  • You need to set a network request interval for user input, such as 0.5 seconds after the user stops input before sending a request, to avoid wasting unnecessary network resources
  • duplicate removal
  • Displaying loading state

Take a look at the home page code:

struct ContentView: View {
    @StateObject private var dataModel = MyViewModel(a)@State private var showLogin = false;
    
    var body: some View {
        GeometryReader { geometry in
            VStack(alignment: .leading, spacing: 0) {
                ZStack {
                    HStack(spacing: 10) {
                        Group {
                            if dataModel.loading {
                                ActivityIndicator()}else {
                                Image(systemName: "magnifyingglass")
                            }
                        }
                        .frame(width: 30, height: 30)
                        
                        TextField("Please enter a repository to search for", text: $dataModel.inputText)
                            .textFieldStyle(RoundedBorderTextFieldStyle())
                        
                        Button("Login") {
                            self.showLogin.toggle()
                        }
                    }
                    .padding(.vertical, 10)
                    .padding(.horizontal, 15)
                    
                }
                .frame(width: geometry.size.width, height: 44)
                .background(Color.orange)
                
                List(dataModel.repositories) { res in
                    GithubListCell(repository: res)
                }
            }
        }
        .sheet(isPresented: $showLogin) {
            LoginView()}}}Copy the code

The above code is very simple, there is no data related processing logic, these data processing logic is all in MyViewModel, the nice thing is, if the View depends on MyViewModel, then when MyViewModel data adaptation, the View automatically refresh.

  • We use the@StateObjectInitialize thedataModelLet the View manage its life cycle
  • useGeometryReaderYou can get the frame of the parent View
  • GithubListCellIs the encapsulation of each warehouse cell, the code will not be posted, you can download the code to view

Let’s look at the contents of MyViewModel:

final class MyViewModel: ObservableObject {
    @Published var inputText: String = ""
    @Published var repositories = [GithubRepository] ()@Published var loading = false
    
    var cancellable: AnyCancellable?
    var cancellable1: AnyCancellable?
    
    let myBackgroundQueue = DispatchQueue(label: "myBackgroundQueue")
    
    init(a) {
        cancellable = $inputText
//. Debounce (for: 1.0, scheduler: myBackgroundQueue)
            .throttle(for: 1.0, scheduler: myBackgroundQueue, latest: true)
            .removeDuplicates()
            .print("Github input")
            .map { input -> AnyPublisher"[GithubRepository].Never> in
                let originalString = "https://api.github.com/search/repositories?q=\(input)"
                let escapedString = originalString.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)!
                let url = URL(string: escapedString)!
                return GithubAPI.fetch(url: url)
                    .decode(type: GithubRepositoryResponse.self, decoder: JSONDecoder())
                    .map {
                        $0.items
                    }
                    .replaceError(with: [])
                    .eraseToAnyPublisher()
            }
            .switchToLatest()
            .receive(on: RunLoop.main)
            .assign(to: \.repositories, on: self)
        
        cancellable1 = GithubAPI.networkActivityPublisher
            .receive(on: RunLoop.main)
            .assign(to: \.loading, on: self)}}Copy the code

Here, I’ll outline the purpose of the main code without going into too much detail, as it was covered in previous articles.

  • $inputText: When we are using@PublishedAdd a prefix to the decorated property$After the symbol, you get a Publisher
  • Debounce (for: 1.0, the scheduler: myBackgroundQueue): when there is input, the debounce will usher in a 1 second time window, if received the new data in 1 seconds, and open a new window 1 seconds, before the window of the void, until no new data 1 seconds, and then send the received data, its core idea is can control the frequent data problem
  • .throttle(for: 1.0, scheduler: myBackgroundQueue, latest: true): Throttle opens a series of consecutive 1-second Windows, sending the most recent data each time the 1-second threshold is reached,Note that when the first data is received, it is sent immediately.
  • .removeDuplicates()You can de-duplicate, for example, if the two most recently received data are swift, the second is ignored
  • .print("Github input")The process by which a pipline can be printed, and the output can be prefixed
  • .mapThe logic of the map above is to map the input string to a new Publisher, which will request the network and eventually output our encapsulated data modelGithubRepositoryResponse.self
  • .decodeUsed to parse data
  • .replaceError(with: [])Used to replace errors and send an empty array if the network request fails
  • .switchToLatest()To output Publisher data, if the map returns PublisherswitchToLatestSwitch output
  • .receive(on: RunLoop.main)Used to switch threads
  • .assign(to: \.repositories, on: self): Assign can be used directly as a property copy using KeyPath, which is a Subscriber

Can you see that? A number of Operators are used throughout the entire process, and you can accomplish almost anything by combining them.

Let’s look at the GithubAPI wrapper again:

enum GithubAPIError: Error.LocalizedError {
    case unknown
    case apiError(reason: String)
    case networkError(from: URLError)
    
    var errorDescription: String? {
        switch self {
        case .unknown:
            return "Unknown error"
        case .apiError(let reason):
            return reason
        case .networkError(let from):
            return from.localizedDescription
        }
    }
}

struct GithubAPI {
    / / / loaded
    static let networkActivityPublisher = PassthroughSubject<Bool.Never> ()/// request data
    static func fetch(url: URL) -> AnyPublisher<Data.GithubAPIError> {
        return URLSession.shared.dataTaskPublisher(for: url)
            .handleEvents(receiveCompletion: { _ in
                networkActivityPublisher.send(false)
            }, receiveCancel: {
                networkActivityPublisher.send(false)
            }, receiveRequest: { _ in
                networkActivityPublisher.send(true)
            })
            .tryMap { data, response in
                guard let httpResponse = response as? HTTPURLResponse else {
                    throw GithubAPIError.unknown
                }
                switch httpResponse.statusCode {
                case 401:
                    throw GithubAPIError.apiError(reason: "Unauthorized")
                case 403:
                    throw GithubAPIError.apiError(reason: "Resource forbidden")
                case 404:
                    throw GithubAPIError.apiError(reason: "Resource not found")
                case 405..<500:
                    throw GithubAPIError.apiError(reason: "client error")
                case 500..<600:
                    throw GithubAPIError.apiError(reason: "server error")
                default: break
                }
                
                return data
            }
            .mapError { error in
                if let err = error as? GithubAPIError {
                    return err
                }
                if let err = error as? URLError {
                    return GithubAPIError.networkError(from: err)
                }
                return GithubAPIError.unknown
            }
            .eraseToAnyPublisher()
    }
}
Copy the code
  • GithubAPIErrorError is a package of various, interested can seeThe AFError Alamofirez
  • networkActivityPublisherIs a Subject, which is essentially a Publisher. It is used to send notification events for network loading. You can see loading in the upper left corner of the videonetworkActivityPublisherImplementation of the
  • URLSession.shared.dataTaskPublisher(for: url)Is the most common web request Publisher
  • .handleEventsYou can listen for events in the Pipline
  • .tryMapIs a special Operator that is used primarily for data mapping but allows throw exceptions
  • .mapErrorFor handling error messages, in the code above, we did error mapping logic,The core idea of error mapping is to map various errors to custom error types
  • .eraseToAnyPublisher()The type used to flatten Publisher, which I won’t go into

In summary, many of you may not immediately appreciate the subtlety of the code above, but the beauty of responsive programming is that we lay out the data pipeline ahead of time, and the data will automatically flow through the pipeline, which is really seconds.

To simulate the login

If network requests are for asynchronous data, mock logins are for multiple data streams, so let’s take a quick look at the UI code:

struct LoginView: View {
    @StateObject private var dataModel = LoginDataModel(a)@State private var showAlert = false
    
    var body: some View {
        VStack {
            TextField("Please enter user name", text: $dataModel.userName)
                .textFieldStyle(RoundedBorderTextFieldStyle())

            if dataModel.showUserNameError {
                Text("User name cannot be less than 3 characters!!")
                    .foregroundColor(Color.red)
            }

            SecureField("Please enter your password", text: $dataModel.password)
                .textFieldStyle(RoundedBorderTextFieldStyle())

            if dataModel.showPasswordError {
                Text("Password must be no less than 6 characters!!")
                    .foregroundColor(Color.red)
            }

            GeometryReader { geometry in
                Button(action: {
                    self.showAlert.toggle()
                }) {
                    Text("Login")
                        .foregroundColor(dataModel.buttonEnable ? Color.white : Color.white.opacity(0.3))
                        .frame(width: geometry.size.width, height: 35)
                        .background(dataModel.buttonEnable ? Color.blue : Color.gray)
                        .clipShape(Capsule())
                }
                .disabled(!dataModel.buttonEnable)

            }
            .frame(height: 35)
        }
        .padding()
        .border(Color.green)
        .padding()
        .animation(.easeInOut)
        .alert(isPresented: $showAlert) {
            Alert(title: Text("Login successful"),
                  message: Text("\(dataModel.userName) \n \(dataModel.password)"),
                  dismissButton: nil)
        }
        .onDisappear {
            dataModel.clear()
        }
    }
}
Copy the code

Specific knowledge related to SwiftUI will not be repeated, the routine is the same, in the ABOVE UI code, we directly use LoginDataModel, all business logic is encapsulated in LoginDataModel.

class LoginDataModel: ObservableObject {
    @Published var userName: String = ""
    @Published var password: String = ""
    @Published var buttonEnable = false
    
    @Published var showUserNameError = false
    @Published var showPasswordError = false
    
    var cancellables = Set<AnyCancellable> ()var userNamePublisher: AnyPublisher<String.Never> {
        return $userName
            .receive(on: RunLoop.main)
            .map { value in
                guard value.count > 2 else {
                    self.showUserNameError = value.count > 0
                    return ""
                }
                self.showUserNameError = false
                return value
            }
            .eraseToAnyPublisher()
    }
    
    var passwordPublisher: AnyPublisher<String.Never> {
        return $password
            .receive(on: RunLoop.main)
            .map { value in
                guard value.count > 5 else {
                    self.showPasswordError = value.count > 0
                    return ""
                }
                self.showPasswordError = false
                return value
            }
            .eraseToAnyPublisher()
    }
    
    init(a) {
        Publishers
            .CombineLatest(userNamePublisher, passwordPublisher)
            .map { v1, v2 in
                !v1.isEmpty && !v2.isEmpty
            }
            .receive(on: RunLoop.main)
            .assign(to: \.buttonEnable, on: self)
            .store(in: &cancellables)
    }
    
    func clear(a) {
        cancellables.removeAll()
    }
    
    deinit{}}Copy the code

If you look closely at the code above, it is declarative and the processing of individual data is clear:

  • We use theuserNamePublisherTo handle the username logic
  • We use thepasswordPublisherTo handle the logic of the password
  • We use theCombineLatestTo merge user name and password data, used to control the status of the login button

It’s really declarative, and if you look at it from above, it looks more like a specification than a calculation of a bunch of variables.

Here, I am too lazy to write non-combine code, you can carefully understand the code, savor the charm.

conclusion

This article is not complex, nor comprehensive, nor is it a complete field experience, but just an example of Combine in a real development scenario. There are three further articles in this tutorial, which respectively explain how to customize Publisher, Operator and Subscriber. It is an advanced content, and we will wait and see.