Refactoring iOS Apps – A Pragmatic Guide
Refactoring is all about taking some existing code and making it even better. "Better" can mean a lot of things, but it encapsulates everything that improves the code, makes it easily maintainable, and increases the overall development speed. Sounds good? Here's the thing: don't expect your boss to allow sidelining of new features with strict deadlines so you can do "refactoring". The reality is, refactoring code is a part of your craft. It should already be an integrated part of your daily workflow as an iOS developer.
Unless your boss is a tech-savvy, he's not going to let you sideline feature development to do refactoring. What you should do is to do continuous refactoring. Imagine that you have a large codebase written completely in Objective-C. Of course, you want to rewrite it to Swift, but this could be months of work, postponing other important work that you need to do. What you should do instead is to write new files in Swift and rewrite existing Objective-C files in Swift that you touch in your daily work. You'll repeat this process while delivering new features (keeping your boss happy) until your whole app uses Swift.
Refactoring for a clean architecture
We want to ensure a nice foundation for our app before we look at the details. Just like you would want to have a nice foundation for a house before you fix the walls.
How should the app be structured?
For the best overview and scalability, you should structure the app in modules. You don't want a monolithic architecture where all your code sits in your app target – unless your app is small and doesn't do much. Breaking out related files into their own module will allow you to create, run, and test pieces of your app in isolation without recompiling the entire app. It also enforces a clear separation of areas/responsibilities and makes you consider how to abstract out various pieces, what should be public/private in each module etc. Lastly, you'll save yourself time by enabling the reuse of large chunks of code in other apps.
A common approach is splitting the app by feature, meaning different feature teams can work on discrete features independently – while maintaining speedy compile-time.
A modular architecture comes with its own downsides. If you make breaking changes to the API of a module, expect to spend more time resolving it in the apps that rely on the module. Also, keep in mind that you're introducing additional complexity in your architecture, so be conscious if this decision fits the needs of your app.
You can create modules in different ways. You can use frameworks in Xcode or dependency managers such as Cocoapods, Carthage or SPM, etc. It's fairly easy and definitely worth looking into.
Refactoring when MVC becomes a bottleneck
Nowadays we have many options when it comes to architecture design patterns at the UI level (MVC, MVVM, VIPER etc). As time goes on and your codebase grows, you may find out that MVC isn't suited for your app anymore – a common problem is when apps start to suffer from having Massive-View-Controllers. This is often the case when your view controllers have too many responsibilities.
I like to mitigate Massive-View-Controllers by applying a pattern that I'm pretty keen on which is the coordinator pattern. It helps you separate everything related to navigation from your view controllers – ultimately making your view controllers slimmer, agnostic to the navigation flow, and thereby more easily reusable.
Afterward, I may realize that one particular view controller is still too hard to maintain efficiently with the MVC pattern. If that's the case, the MVVM pattern is efficient at enabling true separation between your view and the model. Your model is all the data you might need in your view, and being able to abstract out makes your view controllers easier to maintain and significantly more testable. I then refactor that view controller to MVVM, but only for this particular view controller. There's no need to refactor other view controllers where MVC actually works – MVC and MVVM are easily compatible.
Here is a repo of an open-source app I made a while back that uses both MVVM and coordinator pattern in combination (often called MVVM-C) so you can see the patterns in practice.
The process of writing clean code
Now that we've covered the details of refactoring your architecture at both module and UI level, let's look at the low-level details of writing clean, maintainable code.
Whenever I start writing a new method, I'll choose a name that quickly conveys the purpose to the reader. I'll then follow the TDD approach and write a test for its functionality, helping me get clarity of what it's supposed to do. In other cases, I may just write the method in pseudo-code as comments in the code before writing the actual code. When I go about writing the body of the method, I'll make sure that it only does what is described in the name of the method. Methods with side-effects are a common cause of introducing bugs in your code that are hard to trace. Whenever you need your methods to do more things, it's often an indicator that you breaking the single responsibility principle. Break it up into multiple methods.
Many thick books have been written on the topic of writing clean code. My go-to book about writing good, maintainable code is Code Complete by Steve McConnell. You’ll find this book goes into some of the low-level, structural details of writing clean, maintainable code. Read it.
Testing while refactoring
A good practice when doing refactoring in your features is to first run your tests – if there are no tests, write them and make sure they pass. Afterward, you can do your refactoring while having clarity that you don't change any functionality if your tests are still passing.
Writing tests will even help you speed up development by making you understand what you're trying to build and how people will use it. Finally, you'll quickly notice if you break any functionality and catch it the next time you make a change.
While you're at it, set up a CI service for your app to run your tests that are required to pass before merging a PR. I've written several articles on this – learn how to set up CI using Travis CI or GitHub Actions for iOS projects.
In this post, we covered several levels of refactoring your iOS apps. You should look into making refactoring part of the development process through continuous refactoring. Before you start refactoring the low-level details of your code, take a hard look at your overall architecture, and see if you have the right foundation for your projects. It is preferable to keep your projects highly modular. Then, you can go a level down into your UI layer and see if you can refactor it by applying design patterns such as MVVM and coordinator pattern.
Afterward, you can proceed to the process of writing clean, self-explaining methods with only one purpose. Lastly, whenever you go about refactoring your code, make sure that you have tests to ensure that you don't break any existing functionality.
If you think I missed anything, leave a comment and let me know.