IOS Architecture ramblings

When we talk about iOS application architecture, we hear the three Buzz words MVC, MVVM and VIPER, which are all in the same line of logic, constantly splitting logic out of ViewController. From Apple’s official recommended MVC:

(Photo credit)

With the complexity of the system, the function is refined, the integration of View to display data logic out of the independent ViewModel module, the architectural style has become MVVM:

(Photo credit)

As the system became more complex and the responsibility for routing and retrieving data was separated, the architectural style became VIPER:

(Photo credit)

This article would like to discuss a new iOS application architecture scheme from another perspective. The essence of architecture is to manage complexity. Before discussing the specific architecture scheme, we should first clarify the development of an iOS application.

The complexity of iOS app development

For an iOS app, the complexity of its development is mainly reflected in three aspects:

Implementation and style management of complex interface design

IOS App finally presents a group of UI interfaces to users. For a specific App, its UI design elements (such as color matching, font size, spacing, etc.) are basically fixed. In addition, the basic components of the App (such as Button type and input box type, etc.) are limited. However, how to manage, combine and reuse components is a problem architects need to consider, especially in the development process of some apps may have a large number of UI style reconstructions, but also need to clearly control the scope of influence of the reconstructions. The complexity here is essentially the complexity of the design and implementation of the UI components themselves, the way in which multiple UI components are combined, and the reuse mechanism of THE UI components.

Routing design

For a large iOS application, its functions are usually split into features. After such splitting, the possible routes are as follows:

  • APP to APP routing: Call up the current APP from another APP and enter a deep level page (Figure 1).
  • Intra-app routing:
  1. Enter the Home page of App (Figure 2)
  2. From Home page to Feature Flow (Figure 3)
  3. Routing of flow-based pages within Feature (Figure 4)
  4. Page jump among features (Figure 5)
  5. Jump of single point information page shared by each Feature (Figure 6)

According to Apple’s official MVC architecture, this complex jump logic, as well as the preparation of the ViewController before the jump, is wrapped in the UI logic of the AppDelegate initialization and ViewController. The complexity here is primarily the tangled coupling between the UI and the business.

Application Status Management

An iOS app is essentially a state machine. From a UI in one state, the User Action or Data Action returned by an API call triggers the UI to the next state. In order to accurately control application functionality, developers need to be able to clearly understand:

  • What state determines the current UI of the application?
  • Which application states do User Actions affect? How?
  • Which application states are affected by Data Actions? How?

In MVC, MVVM and VIPER architectures, application states are scattered in Model or Entity, and some states are even directly stored in View Controller. When tracking state, it is often required to cross multiple models, and it is difficult to obtain a comprehensive picture of application state. In addition, it can be difficult to track how an Action affects the state of an application, especially when one Action has a different path of impact or can end up changing the state of multiple Models. The complexity here is mainly reflected in the complexity of governing decentralized states and managing non-uniform state-changing mechanisms.

How do you manage this complexity

Given the complexity of iOS app development, how can it be managed architecturally?

Manage the complexity of interface Development using Atomic Design and Component Driven Development

The complexity of the UI is essentially a point on the complexity, the complexity of concentrated in some small details of the system, does not increase the complexity of the overall planning, so the main way to control its complexity is isolated, to avoid a UI components are intertwined, and between a complexity, on the surface of the uncontrolled leads to complexity. At the UI level, the most popular form of isolation is componentization, which was explained in detail in my previous article “Front-end Componentization Solutions” and won’t be covered here.

The App Coordinator manages application routes ina unified manner

Application routes are divided into inter-app routes and intra-App routes, which need to be processed separately

For routing between Apps, there are two ways:

One is that the URL Scheme can jump from other apps to the current App by setting corresponding Settings in the current App. After entering the current App, use the method directly in the AppDelegate:

__Tue Sep 19 2017 17:55:59 GMT+0800 (CST)____Tue Sep 19 2017 17:55:59 GMT+0800 (CST)__func application(_ app: UIApplication, open url: URL, options: [UIApplicationOpenURLOptionsKey : Any] = [:]) -> Bool__Tue Sep 19 2017 17:55:59 GMT+0800 (CST)____Tue Sep 19 2017 17:55:59 GMT+0800 (CST)__Copy the code

Convert the route into the App.

The other is Universal Links, which is also configured in the current App. When users click the URL, they will jump to the current App. After entering the current APP, use the method directly in the AppDelegate:

__Tue Sep 19 2017 17:55:59 GMT+0800 (CST)____Tue Sep 19 2017 17:55:59 GMT+0800 (CST)__func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([Any]?) -> Void) -> Bool__Tue Sep 19 2017 17:55:59 GMT+0800 (CST)____Tue Sep 19 2017 17:55:59 GMT+0800 (CST)__Copy the code

Route into the App.

Therefore, the routing logic between apps is relatively simple, that is, external URLS are mapped to internal routes. This part only needs to add a URL Scheme or Universal Link corresponding to the processing logic of the route within the App.

For internal routes, an App Coordinator can be imported to manage all routes. App Coordinator is a model proposed by Soroush Khanlou in NSSpain 2015, It is essentially an Application to iOS development of the Application Controller pattern described by Martin Fowler in the Patterns of Enterprise Application Architecture. Its core concepts are as follows:

  1. Abstract the concept of a Coordinator object
  2. The Coordinator object is responsible for creating and configuring viewControllers.
  3. The Coordinator object manages all ViewController jumps
  4. Coordinators can derive sub-coordinators to manage different Feature flows

After this abstraction, the route correspondence of a complex App would be as follows:

As you can see from the figure, the UI and business logic of the application are clearly separated and each has its own clear responsibilities. After the ViewController is initialized, all link logic between viewcontrollers is transferred to the App Coordinator system. The ViewController becomes an independent entity that is only responsible for:

  1. Subuiview organizations in their own interface,
  2. Receive data and bind it to the corresponding child UIView for presentation
  3. The user actions on the interface are converted to user Intents on the service, and then transferred to the App Coordinator for service processing.

After the AppCoordinator is introduced, the UI and business logic are separated and each handles its own logic. In iOS applications, the underlying implementation of routing is still the present, push, pop and other functions provided by UINavigationController. On this basis, the iOS community has developed various packaging libraries to better encapsulate the jump interface between viewControllers, such as JLRoutes. Routable ios, MGJRouter, etc. On this basis, we further think about App Coordinator. The core concept of App Coordinator is to abstract ViewController jump and business logic together into user Intents. There is no restriction on how developers implement the jump logic, and the implementation of routes has a wide range of influence in an application. The implementation of route switching is basically a refactoring of the entire App. Therefore, on the basis of App Coordinator, the concept of protocol-oriented Programming can also be introduced to abstract a layer of Protocols between the concrete implementation of App Coordinator and ViewController. Remove the IMPLEMENTATION of UI and business logic completely. After this layer of abstraction, the routing relationship changes as follows:

After the App Coordinator processes routes ina unified manner, the App can obtain the following benefits:

  1. The ViewController becomes a very simple, self-contained UI component with a clear concept. This greatly increases its reusability.
  2. The separation of UI and business logic also increases the reusability of business code. In the multi-screen era, when you need to add an iPad version to your current application, you only need to make a new iPad UI pair and connect it to the current iPhone App Coordinator.
  3. The separation of App Coordinator definition and implementation, UI and service separation makes it easier for applications to do A/B Testing. You can simply use coordinators of different implementations or different versions of ViewController.

useReSwiftManaging Application Status

Once the App Coordinator is introduced, one of the remaining responsibilities of the ViewController is to “receive data and bind it to the corresponding subUIView display”, where the data source is the state of the application. It is not only iOS applications that have the problem of complex state management. In the era of more and more logical forward migration, all front ends are facing similar problems. Redux, the most popular state management mechanism in the Web front end, is designed to solve this problem. ReSwift brings this mechanic to the iOS world. There are several main concepts in this mechanism:

  • App State: All states of an application at a point in time. As long as the App State is the same, the presentation of the App is the same.
  • Store: The object that holds the App State and is responsible for sending Action updates to the App State.
  • Action: Represents an Action to change the State of the App, which itself can carry data to change the App State.
  • Reducer: a small function that receives the current App State and actions and returns the new App State.

Under this mechanism, the state transition of an App is as follows:

  • Start initializing App State -> Initialize the UI and bind it to the corresponding App State properties
  • Business Operations -> Generate Action -> Reducer Receive Action and current AppState Generate new AppState -> Update current State -> Notify UI AppState update -> DISPLAY new State -> Reducer receive Action and current AppState generate new AppState -> Update current State -> Notify UI AppState update -> Display new State -> Next service operation……

During this state transition, note that there are two types of business operations:

  • Operations with the same pace, such as clicking the interface to store interface data in App State; This kind of operation is very simple to handle, just follow the state transition process mentioned above.
  • Operations that are called asynchronously. For example, after clicking query and calling API, data will be returned and then stored in App State. Such operations require the introduction of a new logical concept, Action Creators, which handles asynchronous calls and distributes new actions.

The state transformation process of the entire App is as follows:

State flow for simultaneous operation (image source)

State flow with asynchronous call operations (image source)

After ReSwift manages App status in a unified manner, App development benefits include:

  1. Unified management of application state, including a unified mechanism and a unique state container, makes application state changes more predictable and easier to debug.
  2. Clear logical separation and clear code organization make team collaboration easier.
  3. Functional programming, in which each component does one small thing and is a small independent function, increases the testability of the application.
  4. One-way data flow, data-driven UI programming.

The sorted iOS architecture

After a long introduction, we can summarize the overall architecture of an iOS App that combines App Coordinator and ReSwift:

Architecture of actual combat

The overall architecture principle, “Talk is Cheap “, has been explained above. Let’s take Raywendlich’s App as an example to see how this architecture can be put into practice.

(photo: koenig-media.raywenderlich.com/uploads/201…).

Step 1: Build the UI components

When building UI components, because each component is independent, the team can create multiple UI pages concurrently. When building pages, consider:

  1. How many child UIViews does this ViewController contain? How are subUIViews organized together?
  2. What data does the ViewController need and what is the format of the data?
  3. What business operations does the ViewController need to support?

Take the first page as an example:

__Tue Sep 19 2017 17:55:59 GMT+0800 (CST)____Tue Sep 19 2017 17:55:59 GMT+0800 (CST)__class SearchSceneViewController: BaseViewController {/ / define the business operation interface var searchSceneCoordinator: SearchSceneCoordinatorProtocol? Var searchView: searchView? / / the UI to receive the data structure of private func update (state: AppState) {if let searchCriteria. = the state property. The searchCriter {searchView? .update(searchCriteria: searchCriteria) } }? / / support the business operations of func searchByCity (searchCriteria: searchCriteria) {searchSceneCoordinator? .searchByCity(searchCriteria: searchCriteria) }? func searchByCurrentLocation() { searchSceneCoordinator? Override func viewDidLoad() {super.viewdidLoad () searchView = searchView (frame: self.view.bounds) searchView? .goButtonOnClick = self.searchByCity searchView? .locationButtonOnClick = self.searchByCurrentLocation self.view.addSubview(searchView!) } }__Tue Sep 19 2017 17:55:59 GMT+0800 (CST)____Tue Sep 19 2017 17:55:59 GMT+0800 (CST)__Copy the code

Note: Operations supported by subcomponents are injected externally in the form of property. Names within components are more componentized and should not contain business meanings.

And then the other viewControllers do the same thing, do all the UI components, and when we do that, we have all the UI components of our App, and all the interfaces that our UI supports. The next step is to wire them together and complete the User Journey according to the business logic.

Step 2: Build the App Coordinators to concatenate all the viewControllers

First, add an AppCoordinator to the AppDelegate to transfer the logic of route forwarding to the AppCoordinator.

__Tue Sep 19 2017 17:55:59 GMT+0800 (CST)____Tue Sep 19 2017 17:55:59 GMT+0800 (CST)__ var appCoordinator: AppCoordinator! func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool { window = UIWindow() let rootVC = UINavigationController() window? .rootViewController = rootVC appCoordinator = AppCoordinator(rootVC) appCoordinator.start() window? .makeKeyAndVisible() return true }__Tue Sep 19 2017 17:55:59 GMT+0800 (CST)____Tue Sep 19 2017 17:55:59 GMT+0800 (CST)__Copy the code

Then, load the home page SeachSceneViewController in AppCoordinator

__Tue Sep 19 2017 17:55:59 GMT+0800 (CST)____Tue Sep 19 2017 17:55:59 GMT+0800 (CST)__class AppCoordinator { var rootVC:  UINavigationController init(_ rootVC: UINavigationController){ self.rootVC = rootVC } func start() { let searchVC = SearchSceneViewController(); let searchSceneCoordinator = SearchSceneCoordinator(self.rootVC) searchVC.searchSceneCoordinator = searchSceneCoordinator self.rootVC.pushViewController(searchVC, animated: true) } }__Tue Sep 19 2017 17:55:59 GMT+0800 (CST)____Tue Sep 19 2017 17:55:59 GMT+0800 (CST)__Copy the code

The CoordinatorProtocol for each ViewController was defined in the previous step and will be implemented in this step as well

__Tue Sep 19 2017 17:55:59 GMT+0800 (CST)____Tue Sep 19 2017 17:55:59 GMT+0800 (CST)__protocol SearchSceneCoordinatorProtocol { func searchByCity(searchCriteria:SearchCriteria) func searchByCurrentLocation() } class  SearchSceneCoordinator: AppCoordinator, SearchSceneCoordinatorProtocol { func searchByCity(searchCriteria:SearchCriteria) { self.pushSearchResultViewController() } func searchByCurrentLocation() { self.pushSearchResultViewController() } private  func pushSearchResultViewController() { let searchResultVC = SearchResultSceneViewController(); let searchResultCoordinator = SearchResultsSceneCoordinator(self.rootVC) searchResultVC.searchResultCoordinator = searchResultCoordinator self.rootVC.pushViewController(searchResultVC, animated: true) } }__Tue Sep 19 2017 17:55:59 GMT+0800 (CST)____Tue Sep 19 2017 17:55:59 GMT+0800 (CST)__Copy the code

Complete SearchResultSceneCoordinator in the same way. As you can see from the code above, the jump logic only does two things: initialize the ViewController and assemble the Coordinator corresponding to the ViewController. Once this is done, all the UIs are concatenated according to the business logic. The next step is to use App State to flow between uIs based on business logic.

Step 3: Introduce the ReSwift architecture to build redux-style application state management

First, follow the Official guidance of ReSwift and introduce the ReSwift framework in any way you like. I use Carthage.

Then, the State of the entire App needs to be defined according to the business. The way to define State can be modeled from the business or according to the UI requirements. The author prefers to model from the UI requirements, so that State is easier to bind with UI. The main states in this example are:

__Tue Sep 19 2017 17:55:59 GMT+0800 (CST)____Tue Sep 19 2017 17:55:59 GMT+0800 (CST)__struct AppState: StateType {
    var property:PropertyState
    ...
}
struct PropertyState {
    var searchCriteria:SearchCriteria?
    var properties:[PropertyDetail]?
    var selectedProperty:Int = -1
}
struct SearchCriteria {
    let placeName:String?
    let centerPoint:String?
}
struct PropertyDetail {
    var title:String
    ...
}__Tue Sep 19 2017 17:55:59 GMT+0800 (CST)____Tue Sep 19 2017 17:55:59 GMT+0800 (CST)__Copy the code

Once you’ve defined the model for State, you then need to bind The AppState to the Store and add the Store directly to the AppDelegate as a global variable.

__Tue Sep 19 2017 17:55:59 GMT+0800 (CST)____Tue Sep 19 2017 17:55:59 GMT+0800 (CST)__let mainStore = Store<AppState>(    
  reducer: AppReducer(),    
  state: nil
  )__Tue Sep 19 2017 17:55:59 GMT+0800 (CST)____Tue Sep 19 2017 17:55:59 GMT+0800 (CST)__Copy the code

Bind App State to the corresponding UI

After the injection, you can bind the properties in AppState to the corresponding UI. Note that the receiving data binding should be the top-level ViewController for each page, and all other child views should simply receive the values passed by the ViewController as properties. Binding AppState requires doing two things: subscribing to AppState

__Tue Sep 19 2017 17:55:59 GMT+0800 (CST)____Tue Sep 19 2017 17:55:59 GMT+0800 (CST)__  override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        mainStore.subscribe(self) { state in state }
    }
    override func viewWillDisappear(_ animated: Bool) {
        super.viewWillDisappear(animated)
        mainStore.unsubscribe(self)
    }__Tue Sep 19 2017 17:55:59 GMT+0800 (CST)____Tue Sep 19 2017 17:55:59 GMT+0800 (CST)__Copy the code

And the newState method to implement StoreSubscriber

__Tue Sep 19 2017 17:55:59 GMT+0800 (CST)____Tue Sep 19 2017 17:55:59 GMT+0800 (CST)__class SearchSceneViewController: StoreSubscriber {    
  ......    
  override func newState(state: AppState) {        
    self.update(state: state)        
    super.newState(state: state)    
  }    
  ......
} __Tue Sep 19 2017 17:55:59 GMT+0800 (CST)____Tue Sep 19 2017 17:55:59 GMT+0800 (CST)__Copy the code

After binding, each AppState change is notified to the ViewController, and the ViewController can update its UI based on the contents of the AppState.

With the UI and AppState tied together, the next step is to implement the mechanism for changing the AppState. First, you need to define the actions that will change the AppState

__Tue Sep 19 2017 17:55:59 GMT+0800 (CST)____Tue Sep 19 2017 17:55:59 GMT+0800 (CST)__struct UpdateSearchCriteria: Action { let searchCriteria:SearchCriteria } ...... __Tue Sep 19 2017 17:55:59 GMT+0800 (CST)____Tue Sep 19 2017 17:55:59 GMT+0800 (CST)__Copy the code

Then, the AppCoordinator distributes the corresponding Action according to the service logic. If there is an asynchronous request, it needs to use ActionCreator to request the data and then generate the Action and send it

__Tue Sep 19 2017 17:55:59 GMT+0800 (CST)____Tue Sep 19 2017 17:55:59 GMT+0800 (CST)__func searchProperties(searchCriteria: SearchCriteria, _ callback:(() -> Void)?) -> ActionCreator { return { state, store in store.dispatch(UpdateSearchCriteria(searchCriteria: searchCriteria)) self.propertyApi.findProperties( searchCriteria: searchCriteria, success: { (response) in store.dispatch(UpdateProperties(response: response)) store.dispatch(EndLoading()) callback? () }, failure: { (error) in store.dispatch(EndLoading()) store.dispatch(SaveErrorMessage(errorMessage: (error? .localizedDescription)!) ) } ) return StartLoading() } }__Tue Sep 19 2017 17:55:59 GMT+0800 (CST)____Tue Sep 19 2017 17:55:59 GMT+0800 (CST)__Copy the code

After the actions are distributed, the Reducer injected during the Store initialization receives the corresponding actions and generates a new App State according to its own business logic and the current App State State

__Tue Sep 19 2017 17:55:59 GMT+0800 (CST)____Tue Sep 19 2017 17:55:59 GMT+0800 (CST)__func propertyReducer(_ state: PropertyState? , action: Action) -> PropertyState { var state = state ?? PropertyState() switch action { case let action as UpdateSearchCriteria: state.searchCriteria = action.searchCriteria ... default: break } return state }__Tue Sep 19 2017 17:55:59 GMT+0800 (CST)____Tue Sep 19 2017 17:55:59 GMT+0800 (CST)__Copy the code

Finally, the Store replaced the old App State with the new App State generated by Reducer, and the application State was updated.

The above three steps are a complete architectural practice, and all the source code for this example is available on Github.

conclusion

The iOS app architecture debate to eliminate Massive ViewController has been going on for years, and I’ve been involved in discussions both inside and outside the company. There are no good or bad architectures, just different ones that fit into different contexts. The architectural approach described in this article uses a variety of patterns, each of which addresses some architectural issues, but they don’t have to be bundled together. You can tailor your own patterns to suit your needs. Hopefully, the architectural patterns described in this article will give you some insight.


Thanks to Zhang Kaifeng for the planning of this article, Xu Chuan for the review of this article.

To contribute or translate InfoQ Chinese, please email [email protected]. You are also welcome to follow us on Sina Weibo (@InfoQ, @Ding Xiaoyun) and wechat (wechat id: InfoQChina).