Adapting your existing business solution for SwiftUI. Part 3. Working with architecture

Good day to all! With you, I, Anna Zharkova, a leading mobile developer of Usetech. We

continue to disassemble the intricacies of SwiftUI. The previous parts can be found at the links:

part 1
part 2

Today we’ll talk about the features of the architecture, and how to transfer and embed the existing business logic into the SwiftUI application.

The standard data stream in SwiftUI is based on the interaction of View and a certain model containing properties and state variables, or which is such a state variable. Therefore, it is logical that MVVM is the recommended architectural partner for SwiftUI applications. Apple suggests using it in conjunction with the Combine framework, which introduces declarative Api SwiftUI for processing values ​​over time. ViewModel implements the ObservableObject protocol and connects as an ObservedObject to a specific View.



Modifiable model properties are declared as @Published.

class NewsItemModel: ObservableObject {
    @Published var title: String = ""
    @Published var description: String = ""
    @Published var image: String = ""
    @Published var dateFormatted: String = ""
}

As in the classic MVVM, ViewModel communicates with the data model (i.e. business logic) and transfers the data in one form or another View.

struct NewsItemContentView: View {
    @ObservedObject var moder: NewsItemModel
    
    init(model: NewsItemModel) {
        self.model = model 
    }
    //... - 
}

MVVM, like almost any other pattern, has a tendency to congestion and
redundancy. The overloaded ViewModel always depends on how well the business logic is highlighted and abstracted. The load of the View is determined by the complexity of the dependence of the elements on state variables and transitions to other View.

In SwiftUI, what is added is that View is a structure, not a class , and therefore does not support inheritance, forcing code duplication.
If in small applications this is not critical, then with the growth of functionality and complexity of logic, overload becomes critical, and a large amount of copy-paste inhibits.

Let's try to use the approach of clean code and clean architecture in this case. We can’t completely abandon MVVM, after all, DataFlow SwiftUI is built on it, but it’s quite a bit to rebuild.

Warning!

If you are allergic to articles about architecture, and Clean code turns inside out from a phrase, scroll down a couple of paragraphs.
This is not exactly Clean code from Uncle Bob!


Yes, we will not take Uncle Bob's Clean Code in its purest form. As for me, there is over-engineering in it. We will take only an idea.

The main idea of ​​clean code is to create the most readable code, which can then be expanded and modified painlessly.

There are quite a few software development principles that are recommended to adhere to.



Many people know them, but not everyone loves and not everyone uses them. This is a separate topic for holivar.

To ensure the purity of the code, at least it is necessary to divide the code into functional layers and modules, use the general solution of problems and implement the abstraction of the interaction between the components. And at least you need to separate the UI code from the so-called business logic.

Regardless of the selected architectural pattern, the logic of working with the database and network, processing and storage of data is separated from the UI and the modules of the application itself. At the same time, the modules work with implementations of services or storages, which in turn access the general service of network requests or the general data storage. Initialization of variables by which you can access one or another service is performed in a certain general container, to which the application module (the module’s business logic) eventually accesses.



If we have selected and abstracted business logic, then we can arrange the interaction between the components of the modules as we like.

In principle, all existing patterns of iOS applications operate on the same principle.



There is always business logic, there is data. There is also a call manager, which is responsible for the presentation and transformation of data for output and where the converted data is output. The only difference is how the roles are distributed between the components.


Because we strive to make the application readable, to simplify current and future changes, it is logical to separate all these roles. Our business logic has already been highlighted, the data is always separated. Remain dispatcher, presenter and view. As a result, we get an architecture consisting of View-Interactor-Presenter, in which the interactor interacts with the business logic services, the presenter converts the data and gives it as a kind of ViewModel to our View. In a good way, navigation and configuration are also taken out of View into separate components.



We get the VIP + R architecture with the division of controversial roles into different components.

Let's try to look at an example. We have a small news aggregator application
written in SwiftUI and MVVM.



The application has 3 separate screens with its own logic, i.e. 3 modules:


  • news list module;
  • news screen module;
  • news search module.

Each of the modules consists of a ViewModel, which interacts with the selected business logic, and a View, which displays what the ViewModel broadcasts to it.



We strive to ensure that ViewModel is only concerned with storing data that is ready for display. Now he is engaged in both accessing services and processing the results.

We transfer these roles to the presenter and interactor, which we set up for each
module.



The interactor passes the data received from the service to the presenter, which fills the existing ViewModel bound to the View with prepared data. In principle, with regard to the separation of the business logic of the module, everything is simple. 


Now go to View. Let's try to deal with forced code duplication. If we are dealing with some kind of control, then it may be his styles or settings. If we are talking about the on-screen View, then this:

  • Screen styles
  • common UI elements (LoadingView);
  • information alerts;
  • some general methods.

We cannot use inheritance, but we can quite use composition . It is by this principle that all custom Views in SwiftUI are created.

So, we create a View container, into which we transfer all the same logic, and pass our on-screen View to the container initializer and then use it as a content View inside the body.

struct ContainerView<Content>: IContainer, View where Content: View {
    @ObservedObject var containerModel = ContainerModel()
    private var content: Content

    public init(content: Content) {
        self.content = content
    }

    var body : some View {
        ZStack {
            content
            if (self.containerModel.isLoading) {
                LoaderView()
            }
        }.alert(isPresented: $containerModel.hasError){
            Alert(title: Text(""), message: Text(containerModel.errorText),
                 dismissButton: .default(Text("OK")){
                self.containerModel.errorShown()
                })
        }
    }

The on-screen View is embedded in the ZStack inside the body ContainerView, which also contains code for displaying the LoadingView and code for displaying an information alert.

We also need our ContainerView to receive a signal from the ViewModel of the internal View and update its state. We cannot subscribe via @Observed to the same model
as the internal View, because we will drag its signals.



Therefore, we establish communication with it through the delegate pattern, and for the current state of the container we use its own ContainerModel.

class ContainerModel:ObservableObject {
    @Published var hasError: Bool = false
    @Published var errorText: String = ""
    @Published var isLoading: Bool = false
    
    func setupError(error: String){
     //....
       }
    
    func errorShown() {
     //...
    }
    
    func showLoading() {
        self.isLoading = true
    }
    
    func hideLoading() {
        self.isLoading = false
    }
}

ContainerView implements the IContainer protocol; an instance reference is assigned to the embedded View model.

protocol  IContainer {
    func showError(error: String)
    
    func showLoading()
    
    func hideLoading()
}

struct ContainerView<Content>: IContainer, View where Content: View&IModelView {
    @ObservedObject var containerModel = ContainerModel()
    private var content: Content

    public init(content: Content) {
        self.content = content
        self.content.viewModel?.listener = self
    }
    //- 
}

View implements the IModelView protocol to encapsulate model access and unify some logic. Models for the same purpose implement the IModel protocol:

protocol IModelView {
    var viewModel: IModel? {get}
}

protocol  IModel:class {
   //....
    var listener:IContainer? {get set}
}

Then, already in this model, if necessary, the delegate method is called, for example, to display an alert with an error in which the state variable of the container model changes.

struct ContainerView<Content>: IContainer, View where Content: View&IModelView {
    @ObservedObject var containerModel = ContainerModel()
    private var content: Content

    //- 
    func showError(error: String) {
        self.containerModel.setupError(error: error)
    }
    
    func showLoading() {
        self.containerModel.showLoading()
    }
    
    func hideLoading() {
        self.containerModel.hideLoading()
    }
}

Now we can unify the work of the View by switching to work through the ContainerView.
This will greatly facilitate our lives when working with the configuration of the following modules and navigation.
How to configure navigation in SwiftUI and make a clean configuration, we will talk in the next part .

You can find the source code of the example here .

All Articles