My journey into the world of iOS development has taken me to different work environments, and has given me exposure to different team compositions and development strategies. From improvised teams of uneven talent flying blindly into the unknown, to better established, larger scale organizations with more capacity and know-how paced by the reassuring rhythm of scheduled features, maintenance and releases, to the minimalist experience of single developer projects, to the fast paced, colorful world of agile driven startups.
My latest stint as a consultant was a great opportunity for me to synthesize my experience against the industry’s latest practices. I found that the enterprise iOS development space has made great strides since the early days when there were no benchmarks or blueprints to calibrate against, and that the quality of development and the skill level of the competition has improved.
However I came across a number of misconceptions and blunders which I would like to outline here:
1) Best practices
a) Leveraging UITableViewController’s built-in features rather than using a ViewController with a tableview.
The problem people have with tableview controllers is that they are not convenient for decorating. If you plan to have more elements on your screen like a segmented control or a banner or a button well table view controllers don’t work. So people shy away from using them and would rather have a tableview as a subview of the viewcontroller’s view. However tableviewcontrollers have some awesome build in capabilities, like for example adjusting for the keyboard when there is a textfield in one of the cells. You get that behavior for free and you definitely want to do that because implementing that behavior yourself is messy and it tends to not be future proof since the coordinate system and the orientation APIs tend to change pretty often. So as a rule of thumb you want to take advantage of what Apple gives you for free as much as possible and delegate as much of the complexity to them.
The way to decorate a screen while still using a tableview controller is to use containment.
The pattern that containment embodies is encapsulation (decoupled logic, local reasoning, composition). In a view controller hierarchy, a view controller should only be aware of and managing the content of its descendants, never of its ancestors. The idea behind containment is that view controllers should be interchangeable, and they should still work right out of the box regardless of where they are in the view hierarchy. They could a standalone app, or they could be managing content ten levels deep and their logic would still work the same. You want to avoid at all cost traversing the view hierarchy upwards. At best you’ll get silent failures when the hierarchy is reshuffled which is bound to happen over time, at worst you’ll create failure prone hard dependency between a descendant and its ancestor which will become bad pointers. The way a descendant relates to his ancestor is through the delegate pattern, or for the ancestor to pass a block to his descendants to execute at his discretion.
Now there are a lot of cool things that you can do with containment, for example nested navigation stacks.
Furthermore containment, when it is done right, means that you do not need to store variables for important view controllers such as the root view controller. Calling the appdelegate’s root view controller from anywhere in the hierarchy is a violation of containment. Apple enforces the hierarchy for you and you can use modal presentation from anywhere in the hierarchy, as if you were operating from the root view controller, without ever needing to know how many levels deep you are operating from.
c) Using dynamic datasources rather than incrementing/decrementing rowcount and incoming indexPaths.
Tableviews should feed from dynamic data source objects. Basically what you want to do is assemble a payload of enum values according to conditions. Then use that payload to provide rowcount, and use the incoming indexpath to establish which row you are dealing with. The problem with incrementing/decrementing incoming indexes is that things get very messy once more and more conditions arise.
d) Not storing indexpaths
It is often tempting when dealing with controls which are hosted inside cells, to store the indexpath inside the cell and regurgitate it in the control’s selector to identify which data object the user has interacted with. This is wrong, and it can cause a lot of headaches down the line.
The reason why this is wrong is that no matter how carefully you store the indexpath, it’s bound to become desynchronized at some point as the tableview gets reloaded with changing data. Furthermore there are ways to modify the tableview at the row level, without reloading all the rows. That’s what the insertRow, deleteRow APIs do. In which case all the indexpaths will be shifted up or down and the stored indexPaths will be irrelevant, at best it will be the wrong data object, at worst it will be out of bounds. When you implement a UI which is reloaded asynchronously with a fetched results controller this can potentially happen a lot.
The way to map a control callback to the corresponding data object is either to give it the object’s unique identifier. Or to make the cell the delegate and make the view controller the cell’s delegate and then ask the tableview what is the indexpath of the cell which is calling back, and use that to get the data object from the data source.
e) What I learned
Some people want to factor everything out into re-useable components. While I am not a big fan of the monolithic god class approach and despite the fact that it does take away from the visual quality of the storyboard, I must admit that it does have some far reaching advantages, such as consistency of behavior/appearance and development velocity.
I also warmed up to using custom logic in class extensions for the boiler plate code, such as the containment code or the autolayout code (UIViewController + Extensions) or the custom back button Vs the close button for modals.
I have come to realize that this urge to wrap all the native APIs in helper functions to ‘improve’ on them is a very popular approach in the programming community, and it it often unavoidable to factor out code which is re-used everywhere (such as the addChild, didMoveToParent routine of containment), however I tend to shy away from writing too much shared logic for a variety of reasons:
- Legibility: wrapping a native API call in a custom API with some additional sugar can be confusing to incoming developers as to which API to choose from.
- Reliability: the custom logic might introduce some unexpected behavior which is hard to track down. Using the native APIs as is at least guarantees consistency of outcomes.
- Flexibility: investing too much in this alternative infrastructure prevents a smooth transition away from deprecated APIs and towards newly introduced APIs.
f) Bad patterns
Here are some common mistakes to avoid in the iOS development process:
- Synchronous and navigation driven UI updates, as opposed to asynchronous and data driven.
- Containment violations, upstream dependencies, even retain cycles (don’t store a view controller inside a button for example)
- Lack of awareness of memory and CPU constraints. This is a common pitfall for people who are new to mobile, they are used to working in an environment where resources such as memory and CPU time are limitless, and they don’t understand the cost of abusing them. Experienced devs are constantly aware of the scarcity of memory and CPU cycles, and always strive to optimize a memory footprint or an evaluation performance. As is often the case in programming, it’s not the absolute size of the allocated memory which is a concern, but the rate at which memory consumption increments. As such a good iOS dev will always plan to optimize a payload delivery to consume as little as possible while still fulfilling the requirements (breaking down large payloads into parts which can be handled one at a time). For example you wouldn’t retrieve multiple levels of data to feed a navigation hierarchy. It is much more memory efficient to retrieve additional data as the user makes a selection.
- Not using variables sparingly, disregard for clutter or the legibility of the code.
- Poor understanding of the subtleties of threading with fetching data from a persistent store. This translates to a lot of common problems relating to inheritance of persistent classes, accessing records from the wrong threads, and blocking the UI thread.
2. A case study, asynchronous, or navigation driven UI Updates?
The dilemma I have had to deal before is when is the right time to trigger UI updates. There are two opposing school of thoughts.
a) Navigation driven UI updates
It involves anticipating data activity based on user input. Simple example a user navigates to a “Create object” form. Once he hits Save we can expect the list he is returning to to need an additional row. What could go wrong?
So we put a reloadData in viewWillAppear.
Well people who do that have never had the privilege of working with a late stage product where a UI gets reloaded 15 times in a row every time it comes onscreen because everybody addressed their “missing data” bug by calling reloadData.
Reloading the screen in viewWillAppear seems like it could work. It’s cute when the data is short, but imagine a screen which feeds from a massive data source like a busy calendar. I worked at a company where gathering the calendar data involves a intermediate stage where events would be subdivided into occurrences. So it was somewhat of an expensive operation.
Now instead of doing that evaluation every time the events changed, we were doing that every time the screen was being displayed, even if the result was the same every time. Add to that that we were calling it one additional time every time we had a new missing occurrence bug, and soon enough you had an app that was unusable.
Eventually this data fetching method was seeing so much traffic that we came up with some creative solutions, such as throttling its evaluation. So instead of investigating root cause and cleaning up the mess we just made it worst.
b) Asynchronous, data driven UI updates
Now the solution was to get rid of that viewWillAppear garbage, maintain the data source as a variable and have the UI feed from it. Then when there are changes to the events, re-assemble the data and reload the screen no matter if the screen is visible or not. That way the data is always up to date when the screen is in use, and it’s not reloading needlessly.
The following project illustrates the data driven UI updates approach. It involves a simple list interface with a master and detail pane. The user can add and delete items to lists which are related by a simple one to many relationship, backed by a Core Data schema and store. The Play button kicks off a script designed to simulate high intensity data activity (the stress test) and demonstrates how the interface remains functional despite the extreme conditions.
Items we are going to cover with this project:
- Concurrency across threads, and how to throttle a high volume of concurrent, possibly conflictual operations using a queue and dependencies.
- Core Data, threading in core data and using kvo to service non blocking UI updates.
This pattern fulfills two purposes:
- Not blocking the UI: When doing Core Data the big problem is running expensive queries from the main thread. DB objects are not thread safe and cannot cross thread boundaries.
- Serializing potentially conflictual operations. Basically when you operate from the background you can have multiple blocks being evaluated concurrently (race condition). This can be a problem if you are reading and deleting the same record at the same time. It used to cause a crash. People complained about it so much that Apple removed the assertion. But it’s still a problem in terms of not fetching stale data.
The data activity is dispatched to the background to not block the UI thread, the changes are surfaced using the hierarchy of contexts. The FRCs sense data movement and update the UI accordingly.
The stress test simulates the behavior of a server synchronization, this approach guarantees that the UI will never be stale, without jeopardizing the data activity caused by user input, while having a unique logic path for both, thus abiding by the clean lean and mean doctrine.
3. The clean, lean and mean doctrine
- The code should be as naked as possible, which means not to structure one’s logic around edge cases. This is the reason why I am against using early returns and null checks (guard statements).
- If you fix a bug by bailing early, you’ve addressed the symptom, not the cause of the failure. What’s even worst is that you have silenced the failure, so it will go unnoticed from then on, even though the API is still failing.
- Every assertion failure is an opportunity to re-evaluate the architecture, identify which fundamental flaw has been exposed, and refactor it to account for that flaw.
- If your logic can’t withstand the edge cases you can account for without keying for them, how is it expected to withstand conditions you haven’t planned for (because they will happen, for example massive data influx which can only be experienced in production, or unexpected payloads etc). Basically I believe that your code should behave the same way regardless of the incoming pressure. If the code works only under normal conditions but not extreme conditions then it will not stand the test of time.
- This brings us to the happy path approach. It doesn’t matter whether a condition is extremely unlikely to occur, it only takes one time for the program to crash. So the logic should always be written to account for the most extreme edge case.
- Naked, uncluttered code, with clearly outlined conditions and logic paths, which doesn’t have unnecessary special condition handlers, is clear and legible and incoming programmers are be able to read it like a book so that they are empowered to make informed decisions about changes.
- Code decay happens, implementations will clutter over time and our best chances of not loosing control is to start off with the right discipline.
- The biggest risk is for the codebase to reach a point where there is so much confusion about the original implementation that the bug fixing changes introduce regressions, which in turn introduce more changes which introduce more regressions. I call it the vicious cycle of mediocrity. And it can be very costly , for example at a company I worked for we were spending Sprint after sprint fixing bugs which were regressions of previous fixes.
- The codebase was illegible so the devs did not bother to understand the original implementation, they would just change something, or worst add something and pray for the best.
- The more they were patching, the less the code was legible, the more they were likely to introduce regressions.
- Nobody realized how much time, resources, money was being wasted when the real solution to the problem was to:
- Reverse engineer the original implementation and identify its flaws.
- Re-write it the right way.
- It would have been much more effective and much cheaper. But because of the lack of accountability, reliable metrics, the company was actually under the impression that they were making progress. They would say things like “We closed 57 bugs last week” over and over again. If they had scoped the refactoring effort, a single ticket would have wiped out hundreds of potential bugs.
- Some red flags to watch out for:
- Dead code. Dead code has no purpose except that off of burdening the programmer with unnecessary information. I’ve met programmers who refused to get rid of anything for fear of needing it again, hoarders I guess you could call them. Also commented out code. It should not be unclear wether a piece of logic is relevant, everything that is there should fulfill a purpose.
- Dead code also becomes a problem when an implementation gets patched too many times to account for specific edge cases, in other words when the logic is not context agnostic. When the logic tree starts to grow too many conditions then some of its limbs start to dangle and over time become irrelevant, except that its really difficult to tell which, so nobody dares to snip them for fear of having missed something, and the logic just decays indefinitely.
- Too many variables with similar names, purpose. Use variables as sparingly as possible.
- Cluttered viewWillAppear/viewWillDissapear implementations. Logic which doesn’t map one to one from one to the other, basically whatever is in viewWillDissapear should reverse everything, to a t, that was constructed in viewWillAppear. Avoid viewWillAppear, viewWillDissapear like the plague. They are way too high traffic for any meaningful logic. Use viewDidLoad and dealloc/deinit.
- A cluttered appdelegate. One of the first things I look for to assess the health of a project is how cluttered the Appdelegate is. Somebody once told me to keep the Appdelegate clean and I never forgot it. Novice programmers tend to use the appdelegate as there own personal singleton but that’s not its purpose. Cluttering the appdelegate with global variables can very fast become an undecipherable mess.
- Traversing native hierarchies to get a pointer to a private variable. This can be achieved by using key value syntax, or by doing graph traversal. For example some people will iterate through a private view hierarchy to customize one of its subviews. The internet is full of these creative snippets, which makes them seem legitimate (for example here). This approach should be avoided at all costs because it is not future proof. It is not because a label can be found at the nth level of UIKit hierarchy today that it will still be there in the next update, same for property names. Case in point, UITableViewCell did not always have a contentView container, when that was introduced, a lot of code which was making assumption about that view hierarchy broke.
- Use of timers and hard coded offsets with comments such as “Introducing a small delay to give time for something to be in the right state” Never use Timers, never use offsets. Never use absolute values. Instead of timers you need to serialize your code using dispatch queues.
- Fixing an issue without having been able to reproduce it (by taking a wild guess about what could have gone wrong based on a very dogmatic “at first glance” analysis). Its sounds like a no-brainer but you would be amazed at the number of times I’ve seen seasoned devs jump to conclusions and pushes changes without verifying the validity of their assumptions. It really comes down to the scientific approach, when confronted with a bug, make a hypothesis about what is its root cause, then test it to validate it, if the test passes, the hypothesis is no longer an assumption but a fact and can be safely integrated. Unfortunately as a programmer you will often be working on bugs which are hard to track and difficult to repro. The most challenging bugs are those which come from production and affect a very small percentage of users, because they manifest themselves only in very narrow windows. Two things can help:
- QA should invest a lot more time in trying to repro these so as not to waste valuable development time, instead of giving up early.
- The code can be modified, based on one’s understanding of the problem, to extend the critical window of conditions within which the bug happens. For example if a crash is caused by a race condition involving high frequency transactions which can collide only within a matter of milliseconds, then the evaluation of the transactions can be extended by sleeping the thread for a longer delay, to increase the window within which a collision can occur. Whenever someone claims to have fixed a crash, I demand they show me a screenshot of the crash being caught in the debugger.
Software development in general, and iOS development in particular, is a complex affair in that the daily activity of the technicians is not easily quantifiable or regulated. Attempts at measuring the performance of developers often miss the mark because the quality of code is not easily measured.
- Measuring physical volume of code is misleading because quantities are worthless if the code is of bad quality. In some cases, high quantities of code are actually worst than no code at all, as they could indicate the unraveling of the core architecture.
- Measuring duration of task implementation is also misleading in that bad implementations will inflict more delay spent fixing the corresponding problems.
Throughout my professional experience, I have found that companies which emphasize process, outline clear channels of accountability, and enforce clear channels of authority involving a hierarchy of technicians led by a knowledgeable and pragmatic architect tend to do better than others.
Further I find that most companies, pressured by the Agile environment, often forego the practice of writing thorough, incremental and up-to-date technical documentation. In such an environment the code base becomes a free-for-all, where critical logic components are at the mercy of misguided, uninformed decisions intended to resolve superficial problems. In the FAA there is the concept of Required Inspection Items, they are critical components which affect the airworthiness of the aircraft and require special clearance to tinker with, I don’t see why we couldn’t enforce a similar concept in development.
I believe that the contributions of the developers could be measured with the following scale:
- Any update to the code base must fulfill the condition that the outcome is at least as good as the original. By that I mean that no existing functionality must be deteriorated by the update. When a task implementation results in the deterioration of the original functionality in any way, it is not acceptable.
- I find that pull requests which have a low ratio of lines added to lines removed are likely to be better. A good thorough programmer will have removed almost as much as he’s introduced. A change set which adds a lot but removes nothing is suspicious in my opinion.
- It is critical to assign blame when handling regressions. When the prevailing stability has been jeopardized by fresh bugs, it must be quickly linked back to the corresponding changeset and its author must be tasked with repairing the regression. It is a mistake to assign regressions to developers who have not introduced them as it dilutes the sense of responsibility, not to mention that the author is in a much better position to investigate what went wrong. Development environments where there is no such accountability fester, as there is no distinction between regressions and existing flaws, and the relative quality of different developer’s work cannot be measured, therefore the bad developers cannot be identified. I am not advocating for a toxic culture of finger-pointing, but rather for a fact based trackrecord for different developers which show how likely somebody is to introduce regressions. Nobody would be immune from such a point based ranking, and I expect even the best developers will get hit every once in a while, but this will bestow upon the programming force a sense of responsibility and ownership, while giving them an incentive to forego the temptation of taking credit for quick and dirty solutions with little regard for side effects.
- This is why traceability is critical. A clear path can be established between the offending changeset and its author with the following setup:
- Smart commits in git which link back to the originating JIRA issue.
- automated build with some kind of CI client like Jenkins.
- A quality management system which plots status of each test case in a test plan against specific builds.
- When a build or a test fails for a specific build, the failure can be quickly traced back to the JIRA issues which went into that build and their authors. Anything less will sacrifice a low cost opportunity to maintain product stability while enhancing accountability.
Finally, I believe that Quality Assurance departments should not be granted unreasonable authority over the development agenda. In keeping in line with the principle of clear channels of accountability, no QA activity should happen outside of a test plan composed of test cases written in collaboration with the originating developer, and populated with historical data across key releases.