Swift 3 Migration for a Mixed Swift/ObjC Codebase

Enter Xcode 8 in late September 2016; Swift 3 now the default version of swift to develop iOS apps; Xcode 8.2 last version supporting Swift 2.x; It is past time…

… To Migrate or Not To Migrate

Our first step was to decide if we wanted to migrate to Swift 3. In the past there would have been no choice but to bite the bullet. However, this time around Xcode 8 offered a build flag that let us use legacy versions of Swift.

It turns out that the legacy feature is meant only for version transitions.

Another issue that forced us to consider against migration was the substantial amount of changes. The Swift team and the community have been very busy and Swift 3 showed the development effort of a young language.

Unfortunately, this version did not come with ABI compatibility, which directly hinted at a similar migration another time around when Swift 4 released.

Not migrating now would’ve meant double work next year because we would be porting features from 3 and 4 at once. So, no big surprises…we decided to go ahead.

The Process

It rapidly became clear that the only way forward was a one-shot migration. Xcode only allows compilation with a single Swift version, so once the show is on the road, all the changes need to be merged at the same time. This created several logistical problems, spanning from locking the team out of working on any Swift file to generating massive pull requests (60k lines all told).

This is not always easy, especially if you are relying on the compiler errors to guide you on the next piece of work.

That said, we now know there is an alternative.

Remove most of your classes from the target and build separate modules with them. This way they can coexist with different versions of Swift.

However, don’t believe this to be a totally painless process. We don’t really know because we decided not to take that road.

We then fired the Xcode migration tool (Edit->Convert->to Current Swift Syntax) and simply committed the diff. We then proceeded to analyze this raw pull request, going through each file in the diff, making notes.

Unexpectedly, the migration only did a half job towards a compilable codebase.

Our next step was to open the issue navigator and going through the list of errors and warnings one by one (yes, warnings because we had one chance to start afresh and we didn’t want to waste it).

Most of the issues came with a handy fix-it, which was the right fix, most of the time. Sometimes it was better to rearrange or rewrite the code to make everything clear. A migration was a good excuse for a lot of us to look broadly around the codebase and redefine some practices, especially within a language that was new to everyone.

As we went along, the errors seemed random and their counts fluctuated heavily. After a while, it became easier to spot multiple patterns that could be bulk-fixed with a global search & replace. Eventually, the code compiled.

The Task List

With the preliminary testing passed, we now focussed on the list of tasks and the notes we collected. All of that code was technically correct but would make the eyes bleed. (Make sure you don’t open the blame panel on the right, the author is very likely you!)

Following, was the list of things we noted during our migration. Most of these seemed to be common to any project of reasonable size.

fileprivate. The migration will change all your private declarations to fileprivate. This is not necessarily correct as some were actually meant to be private.

IndexPath. Some changed but some didn’t!

UIControlState() -> .normal -> UIControlState(). An OptionSet that is set to it’s default RawValue can be instantiated as an empty init (ex.: UIControlState()). That was not as descriptive as `.normal` so we made sure all of them were `.normal`. {UIViewAnimationOptions() also was changed to `.curveEaseInOut`.

Enum cases to lowercase. Some enums will change to have a lowercase first letter, some will not. The migration tool will deal with any specific keywords that are conflicting like default, by using reverse apices (ex: `default`).

Are you really Optional: Some Objective-C APIs had changed and produced optional types. If that was a self-created Objective-C API we had to make sure the nullability identifiers are set correctly.

Objective-C Nullability Identifiers. In Swift 3, each Objective-C imported class that has no nullability identifiers goes from being force unwrapped to optional. The fast solution was to if let or guard let everything in Swift, but after doing that, we also reviewed them on the Objective-C side of things.

Optional comparable. Because of changes in the optionality of some APIs or indeed many of the Objective-C ones (see above), the migration tool wrote some comparable functions to be applied to generic optional types (`func < <T : Comparable>(lhs: T?, rhs: T?) -> Bool`). This was a bad idea and logic needed to be changed and that code deleted.

NSNumber. Swift 3 does not automatically bridge a number to NSNumber (or any NS class for that matter), but the cast did not need to be forced in most cases.

DispatchQueue. We loved the new DispatchQueue syntax, however, the migration tool messed some conversions up. Also, every `dispatchAfter` in the code had to be modified to avoid double conversion to nanoseconds. As most API used a delay in seconds, it necessitated multiplying that by NSEC_PER_SEC, well the migration tool will just take that logic and divide by NSEC_PER_SEC.

NSNotification.Name. The NotificationCenter now adds observers by NSNotification.Name instead of String. The migration tool wrapped the given constant in a Notification.name while we preferred to hide that logic in the constant itself by assigning the Notification.name to the let variable.

NSRange to Range<Int>. Most string APIs now take Range<Int> instead of NSRange. It is now also much easier to work with them by using literal ranges (0..<9).

_ first parameter. Swift 3 naming convention has changed to allow implicit naming of the first parameter in a function. Most of our API and API calls changed automatically, some didn’t. To make matters worse, some suggested API changes made functions difficult to read. We ended up using NS_SWIFT_NAME for those Objective-C names that are not Swifty enough.

Objective-C class properties. Many class calls in Swift are now represented by class properties as opposed to class methods (ex.: UIColor.red). We modified our Objective-C and converted static getters to static properties to allow it to work as expected in both worlds.

Any and AnyObject. Objective-C id types are now cast to Any instead of AnyObject. The conversion was pretty easy to fix.

Access Control. We already fixed private and filePrivate. But it was also worth reviewing uses of open, public and internal. There was an internal discussion for any exceptions and in general `internal` was our way to go.

Conclusions

The process of migrating ~250 Swift files took around 3 weeks and 2 people. Having 4 eyes instead of 2 became even more important when the focus of the project was less about code logic and more on making sure that no new bugs were introduced because of typos, rename operations and reordering.

A migration is an effective way to leave your code in a better place. It does that by updating the code to a newer version but it is also an opportunity to spot unconventional behaviors as well as outdated ones. It is important to note those findings and update the team’s coding conventions (or start one if you don’t already).

There are 2 reasons for doing so:

  • The first one is for reference for anyone in the future.
  • The second is the exposure of the ideas in the process of updating/creating one.

It is very likely that a migration PR is so boring that it won’t have much traction. However, a different PR with the newly changed standards as well as the motivation for the choices made, is much easier for the rest of the team to follow and digest.