Millions of Customers. Billions of Dollars: iOS Architecture at Scale

AltConf 2018

I didn’t win a golden ticket to WWDC this year. WWDC last year was a refreshing mid-year break and the energy from Apple and its employees was reinvigorating. Everything is super dialled in and everyone is very enthused to be there.

AltConf this year was a close approximation.

I also wanted to take the opportunity to share some lessons I’ve learned over the years. The way Apps are being built has been shifting over the last few years. I review what has been working out and open questions that still remain.

Dominik Wagner wrote a reasonable take on why he is opposed to the current direction of Swift. A direct Fisking would be uncouth because his points are well thought out. A real-world example of transitioning to Swift working well is the best counterevidence.

How Large?

I have been consulting at a very large e-commerce retailer. The whole App is almost half a million lines of code. And it is at the point now where there is more Swift than Objective-C.

LanguageFilesCode
Swift2845246570
Objective-C1289164414
C/C++ Header194837311
Objective-C++4716602
C1611861
JavaScript787494
Total6314493367

There is a bunch of JavaScript in the App. This is because some of the App is built with ReactNative. 🤢🤮

That doesn’t account for all of the JavaScript because ReactNative functionality is deployed into a container at runtime using a technology called CodePush.

Most of my recent experience with building the App is with the product module. The product module is almost 50,000 lines of native code. It’s gotten to the point where it is more Swift than Objective-C.

Most new code gets written in Swift.

LanguageFilesCode
Swift32725670
Objective-C8416274
GraphQL93004
C/C++ Header861405
JavaScript124
Total50746377

What is Good Architecture?

We can’t talk about good code in isolation. We must speak about it in the context of an App with good architecture.

So what is architecture?

We may talk about files and folders, classes and functions.

I propose that architecture is a set of decisions. Decisions may reduce the set of future decisions you may be able to make.

This may be a bad thing. This may be a good thing.

This is a good thing when it is obvious how views should be constructed, how model state should change, how controllers should respond to actions, how the app should navigate.

This is a good thing when code becomes uniform and obvious.

But how do we define good?

Given a large codebase, making decisions that turns a module with 50,000 lines of code into a module with 100,000 lines of code are not good decisions.

Making decisions that turn an App that is 500,000 lines of code into a million lines of code app are not good decisions.

I propose that good architecture is small, simple, and explicit.

Examples of Good

Swift has features that makes state mutation simple and explicit.

var heroImages: [URL] {
  didSet {
    preProcessImages()
  }
}

Delegation is a very common pattern in Cocoa. And an App can’t be built without interacting with UIKit, AppKit, and other Cocoa frameworks. Delegation is common because it is fairly simple and straight forward.

Notice that the delegate is a weak variable in the code below. Ownership is also simple and straight forward. Bugs arise when memory ownership is not clear and explicit.

weak var delegate: Tappable?

@IBAction func somethingTapped(sender: UIButton) {
  delegate?.somethingTapped()
}

And Swift closures make callbacks simple and straight forward, too.

Notice the capture list. Once again, this is a Swift feature that makes ownership clear and explicit.

cell.somethingHappened = { [weak self]
}

Observation was vastly improved in Swift 4. The keyPath feature reduces the amount of boilerplate that must be written. And picking out a changed value is explicit and simple.

let observation = child.observe(\.productID) { 
  (productID, change) in 
}

Serialization

Objective-C ➡️ ObjectMapper ➡️ Codable

Objective-C

Originally, serialization was written in Objective-C. In the code below, mo_key is a macro that performs magic.

If you have to reach for a macro in C or Objective-C, that means there is something that is missing from the language. Also take a look at that isValidModel function. Serialization validation is a two step process.

A common pattern in Objective-C is to pass a pointer to NSError back to a caller as an argument. This solution also leaves much to be desired. It is not a language level feature and error checking is not enforced.

@interface ProductModel: ModelObject {
}

@implementation ProductModel
- (NSDictionary*)mappingDictionaryForData:(id)data {
    return @{ @"productID": mo_key(self.productID) };
}

- (BOOL)isValidModel {
}
@end

ObjectMapper

The next step was to use a third-party framework called ObjectMapper to serialize data.

ObjectMapper uses operator overloading. This is a perfectly cromulent use of operator overloading as it is an improvement over macros.

And take a look at that optional initializer. Validation has become a language feature, too.

class Product: Mappable {
  init?(map: Map) {
  }
  func mapping(map: Map) {
    productID <- map["productID"]
  }
}

Codable

The next step was to moving to Codable after it was introduced in Swift 4.

CodingKeys are flexible enough for serializing data. And notice the throws. A language feature is also used for validation.

class Product: Codable {
  var productID: Int
  enum CodingKeys: String, CodingKey {
    case productID
  }
  init(from decoder: Decoder) throws {
    let container = try decoder.container(keyedBy: CodingKeys.self)
    self.productID = try container.decode(Int.self, forKey: .productID)
  }
}

The best thing about Codable is that is integrated with the compiler and toolchain. It may generate all the code for if:

class Product: Codable {
  var productID: Int
}

Wrapping Up

With large Apps, reading code is as important as writing code. Code gets read way more often than it is written.

Having small, simple with clear error handling and explicit memory ownership is easier to read and to maintain.