iOS Architecture Patterns for Large-Scale Development, part 2: Dependency management
Sep 28, 2020 • 13 min read
Sep 28, 2020 • 13 min read
After we made all the necessary decisions regarding the general architecture, it was a time to think about the implementation details. We needed to build all the necessary screens and organize navigation between them. Navigation details have already been described in the first part of the article, so let’s get into the subject of feature architecture.
In our projects we use various architectural patterns such as MVVM, MVP, VIPER, etc. They all have one thing in common - they make the view layer as thin as possible. The view layer knows nothing about application logic, data storages, and device services. It just displays the formatted data provided by the view models or presenters.
The view layer does not read and write data from the data storage directly, instead it uses presenters or data providers for this purpose. This approach has several advantages. First, it solves the problem of massive view controllers from the MVC architecture. We have no controllers with a thousand lines of code that are difficult to read and understand. On the contrary, the view controllers only display the formatted data from the view models and handle user interactions. The code becomes simple and understandable.
Secondly, the view layer is becoming more independent and replaceable. Mobile app design trends change quite often and a thin view layer allows us to change views without affecting the rest of the application. We can also change the business logic or the data storage layer without any changes in the view layer.
And last but not least, it significantly improves the testability of the view layer. The view layer depends on the UIKit framework and it has life cycle methods. That is why it is difficult to write unit tests for the view layer. If your view controller doesn’t have any business logic and only displays the data from the view model or presenter, you don’t need to test it. You can just test the view model and presenter. So, it doesn’t matter what architectural pattern you choose, if the view layer is thin you are on the right path.
The real applications screens can have a rather complicated layout. It can consist of several elements, and each of these elements has its own architecture and can be reused on other screens. Many applications, especially e-commerce applications, have a complicated UI on their home screens. In most cases, these screens have dynamic content. This content depends on many factors, such as upcoming public holidays, sales, new arrivals, etc. Sometimes we need to use several APIs to load this content. In order to solve this problem, we use an approach with a dynamic data source.
Each element of this data source is responsible for a specific section on the home screen. It takes data from the API, makes additional requests if needed and configures an appropriate view. We create an array of these data source elements based on the home page API response. Such an approach allows us to create the dynamic content of the page by combining appropriate view models.
The next question is how to display the views created by the data source elements. There are several common approaches in building dynamic screens:
All of them have their own advantages and disadvantages, so let's look at them in more detail.
The main advantage of the table view and collection view is lazy data loading. We do not keep all views in the memory and load them only before they appear on the screen. Table view and collection view also use a cell reuse approach that also reduces memory usage. So, in the case of a large screen with many components it could significantly improve the performance of the application in terms of memory.
And what about disadvantages? In the case when you have only the API call on the screen, everything works perfectly. But problems begin when you need to make additional calls to load data. These processes are asynchronous and you need to update the data source and reload the view every time the API call ends. While you could wait until all the API calls end and reload the view only once, it will degrade the responsiveness of your screen.
The reloadData() method of the table view (as well as collection view) runs several asynchronous processes and if you call it asynchronously you can crash the app with inconsistent data source exceptions. So you therefore need to be extremely careful when you update data in a table (or collection) view asynchronously.
Another disadvantage of the UICollectionView is that it doesn’t support automatic cell sizes. iOS 13 provides the compositional layout for collection views that supports the automatic cell sizes, but it works only on iOS 13.
The main advantage of UIStackView is a simple way to update the views. If you need to show/hide some particular view you just change the isHidden property for this view and that’s it. You don’t need to change the data source and reload the view. It’s very useful when we have several API calls and need to reload the screen asynchronously. We can add the view to UIStackView immediately when the screen appears and hide it until the appropriate API call ends. We don’t need to think about consistency of the data source every time we reload the view and it makes our life easier.
But UIStackView also has several disadvantages. The main one is performance. Benchmark measurements show that UIStackView performance is almost two times lower than that of auto layout and many times lower than that of manual layout calculation. This can cause scrolling lags on older devices. It also doesn’t support lazy loading and reusing of views. This fact could lead to memory issues in the case of screens with lots of elements.
The second disadvantage of UIStackView is its limited layout options. UIStackView supports only combinations of vertical and horizontal stacks of views. In the case of a complicated layout (with floating elements, clipped headers, etc.) it will be difficult to implement this using UIStackView.
One more disadvantage of UIStackView is the fact that it is not a view. It doesn’t have its own layer. It means we cannot change properties such as alpha, border size, and color, etc. We also cannot apply layer masks and layer transformations to it.
Summarizing all of the above, UIStackView provides an easier and safer way to refresh view frequently. It is suitable for cases when you have a limited number of elements on the screen and quite a simple layout. In cases of a large number of subviews, it could lead to memory and performance issues.
UITableView and especially UICollectionView work perfectly for complicated layouts with a large number of elements. UICollectionView allows us to implement any kind of layout. But you should be extremely careful if you need to refresh the view asynchronously. There is no way to hide or show elements without the data source changes and it is easy to catch inconsistent data source exceptions. So, you need to make a decision based on your screen needs and requirements.
In October, 2019 Apple introduced a new framework for building user interfaces, SwiftUI. But SwiftUI is not only a UI framework, it also changes the whole application building process. So, you can’t just take your project built on UIKit and transfer it to SwiftUI. It would be like building a completely new project. SwiftUI changes not only the UI layer but the whole application architecture. You can start a new project with SwiftUI but transferring the old one is not a good idea.
Let’s look at an example. Almost all e-commerce applications have a product details screen. This screen contains the product description, photos, configurations (like size, color, price set). It could also contain banner ads and similar products. These sections may be dynamic depending on the type of product because shirts and sofas have different configuration parameters and user input in one section may lead to changes in another section.
For instance, if the user selects a specific color we want to show the product photos with that color. To implement this we first need to create the data source. The root element of the data source should create an array of section models based on the product type. Each model should make additional API calls if needed and configure the view (in the case of UIStackView) or the cell (in the case of UITableView/UICollectionView).
So we now need to decide what kind of container we want to use to display these views. To do this we need to realize what problems need to be solved:
For this screen we are going to use the UIStackView as a container for the views. But if in the future the design changes and there will be more repeating elements or the layout becomes more complex, then perhaps we will change our mind in favor of the UITableView or UICollectionView. Thus, each time you need to choose which approach to use, you should make a choice based on the needs and tasks of a particular screen.
Our rewrite journey took approximately 12 months and is now almost complete. We set ambitious goals of taking the project development process to a new level but with a minimum of required effort and a smooth transition from the existing setup. There were some limitations and obligations but we did it successfully by balancing between business goals, engineering excellence, and level of expertise. Let’s summarize what we achieved:
We incrementally rewrote 70 - 80 percent of major features in less than 12 months. These numbers are quite impressive and prove the implementation speed increase. It is worth mentioning that there were not only existing features rewrites but also a new features kickoff. New features were implemented even faster than existing ones. The metrics show that the volume of work done to get an increase was reduced, even despite the team capacity dropping down a little.
Thanks to the modularity and granular architecture, we were able to get full profit from feature toggles and kept two versions of functionality after application deployment with an ability to quickly switch between them remotely. Rewritten functionality successfully passed combat testing during a holiday season. There were no force majeure situations and feature toggles kept 100 percent of the time switched to the new versions of features.
Are managers and stakeholders happy? - Definitely, yes.
We set up a really clear development process and an easy-to-use environment for the whole team. Splitting into four architecture tiers gives a clear vision for every developer where to place some code. Every rewritten feature is organized as a standalone reusable module. The separate feature is maintained by a separate team.
A parallelization of feature-teams work has been increased and there are fewer collisions between them. Every team has its own pet project for performing a module integration dry run as well as debugging. At the same time, features follow a common template, which speeds up kicking off new features and the onboarding process for the developers.
We declared and developed a clear flow for cross-module communication as well. Even complex composite modules don't bring much pain now. Composite pages can be easily decomposed into smaller pieces and developed in parallel by multiple teams saving valuable time.
Having granular modules and well-split application layers allowed us to implement applications for a second brand much faster with minimum configuration and assembling code requirements. We decided to go with a mono repository and we don't regret this decision at all taking into account all the conditions we had. Our quickly developed selective build system in conjunction with modularity and dependency manager greatly cut down the average time of the CI/CD pipeline - from 40min to 13 min, and it’s not the limit.
Are our developers happy? - No doubt.
We increased the number of automated checks. Here is a simple principle - the sooner we can find the issue - the faster it’s fixed. The source code and git history linting save valuable time during code review.
As for unit tests coverage - new features have drastically greater coverage and reliability. The routine task of generating mocks for writing unit tests is automated and gets its own tool integrated by default via the dependency manager. This, combined with fast selective tests runs, encourages developers to write unit tests even more willingly.
Modularity also opened up the possibility for setting up a process of regular automated integration tests and more regular UI test launches. Now UI tests can be run on demo projects on a regular schedule. The number of production and testing phase defects are reduced. The appearance of bugs in production has decreased as well. Most of the defects are caught even before regression testing starts.
We also established control over mandatory 3rd party SDKs and reduced their compiling time. Adding a new 3rd party is now automated and simple.
Are the users of the application happy? Definitely - fewer defects, more stability, no wasted time.
To sum up - everybody is satisfied with the results we achieved. The Initial goals were achieved and even surpassed. Of course, the sky’s the limit and we can always do better. So stay tuned and we will share our new ideas and our experiences with their implementation.