Migrating Runtastic iOS Apps to Swift 4

by Manuel Lopes
Software Engineer iOS @ Runtastic

Earlier today I was wondering what adjective would best evoke the emotional landscape surrounding our iOS team for the last few weeks, and I think exhilarating would come pretty close. We launched a new app ?, Apple released iOS 11, we started using Xcode 9 in production, there’s an exciting new iPhone approaching fast, and of course, there’s the modest main character of this post, Swift 4.  

This new version is the result of 30 proposals created by the community over the course of a year.

It’s not an overwhelming reshape like Swift 3 was, and it’s not packed with new features like Swift 2, but there are really nice additions. This post highlights some of the challenges we came across while migrating our apps and hopefully will offer some useful insights for those of you who are planning to do so soon.

Before you start

There are a few things that might be valuable to keep in mind before starting the migration:

  • Xcode 9 lets you use both Swift 3.2 and Swift 4 for different targets independently. This means you can, for example, migrate the app and test targets to Swift 4 while private frameworks or libraries are kept in older versions.
  • Xcode’s built-in migration assistant is a good starting point, but it’s also likely to generate code that simply won’t work in your project. We made use of it but mostly only as a quick way to pinpoint source changes. For the most part, we discarded the automatically proposed code in favor of our own implementation.
  • The “Convert to current Swift syntax” option in Xcode’s “Edit” menu will launch the migration assistant and prompt you to choose between two modes:

“Minimize Inference”

This setting will try to migrate the code while keeping the additional @objc annotations to a minimum. All the required additional annotations will show up in the Xcode issue navigator once the app is running, and you can add them later if needed. If you go with this option, please make sure the “Swift 3 @objc Inference” (SWIFT_SWIFT3_OBJC_INFERENCE) build setting is set to “On” in the project targets before and during the source code changes required by the Swift 4 compiler. Once all compiler errors have been fixed, and your project is running, this setting allows Xcode to report runtime issues related to deprecated @objc inference, which you can then start addressing individually. As a migration final step (after runtime issues have been fixed), this build setting can safely be set to “default” in Xcode (see more info on SE-0160 below).

“Match Swift 3 behavior”

Using this setting, the migration assistant adds all the required @objc annotations to your code, but there’s a good chance you’ll find this to be a heavy-handed solution, in large part because you might prefer to use @objcMembers annotations instead.

Some of the Swift 4 migration challenges for us

Distinguish between single-tuple and multiple-argument function types

Swift Evolution proposal SE-0110 was implemented in Swift 4, and it’s fated to play a lead role in the breaking changes developers have to deal with when switching to the latest version. Let’s look at a couple of examples to try to understand why:  

In Swift 3, we could write:

// Swift 3
let user: ( _ name: String,  _ age: Int) -> Void = { user  in
    print(user)
}
user("Manuel", 20) //  ("Manuel", 20)

With Swift 4, the same code would result in an error:

error: contextual closure type ‘(String, Int) -> Void’ expects 2 arguments, but 1 was used in closure body

This happens because, unlike with Swift 3, there’s a clear distinction now between functions that use a tuple as argument and functions that take multiple arguments. Using the example above, to arrive at the same result, we would have to explicitly make the closure argument a tuple, e.g. by adding parenthesis:

// Swift 4
 
let user: ( (String, Int) ) -> Void = { user  in
    print(user)
}
user(("Manuel", 20)) // ("Manuel", 20)

This seemingly simple change is likely to show up in your code under different forms. Here’s another simple variation:

//  Works in Swift 3, error in Swift 4
let user : ((String, Int), Float) -> Void = { user  in    
    print(user.0)
}
user(("Manuel", 20), 100.0)   //  (“Manuel”, 20)


// Swift 4 version
let user : ((String, Int), Float) -> Void = { user, time  in
           	  print(user)
}
 
user(("Manuel", 20), 100.0)  //  (“Manuel”, 20)

New @objc inference rules

A second proposal that landed with Swift 4 and had a sizeable impact on our code base was SE-0160 Limiting @objc inference.

For a Swift class to be accessible and usable in Objective-C, it must be a descendant of an Objective-C class, or it must be marked @objc. In Swift 3, this meant that if a Swift class inherited from NSObject, the compiler would automatically expose all its properties to Objective-C. This change limits that automatic behavior to only those cases where the declaration must be available to Objective-C. In practice, this means we now need to explicitly annotate the Swift classes and properties that should be used in Objective-C. Let’s look at a few examples:

class MyRun: NSObject {

  // start() is automatically exposed to Objective-C in Swift 3
// but not exposed to Objective-C in Swift 4
  func start() {} 
}
class MyRun: NSObject {

  // exposed to Objective-C in Swift 4
  @objc func start() {}
}

In case you need to make all the properties and methods of a Swift class visible in Objective-C, you might consider using @objcMembers annotations:

@objcMembers
@objc(RTMyRun)
class MyRun: NSObject {
    
    // exposed to Objective-C in Swift 4
    func start() {}
    
    // we can opt out by annotating the function with @nonobjc
    @nonobjc func pace() {}
 
    // no need for @nonobjc because tuples are not expressible in Objective-C anyway
    func speed() -> (Int, Int) {}
}

Handling changes in UILayoutPriority

In Xcode 8, we could manipulate constraint priorities like this:

// Swift 3
let heightConstraint = progressView.heightAnchor.constraint(equalTo: heightAnchor)
heightConstraint.priority = UILayoutPriorityDefaultLow + 10

However, in Xcode 9, UILayoutPriority is no longer exposed as a Float, which makes these simple calculations look cumbersome:

// Swift 4
let heightConstraint = progressView.heightAnchor.constraint(equalTo: heightAnchor)
heightConstraint.priority = UILayoutPriority(rawValue: UILayoutPriority.defaultLow.rawValue + 10)

A more elegant solution might be to extend UILayoutPriority with two simple operators that manage these situations:

// Swift 4
extension UILayoutPriority {
    static func + (lhs: UILayoutPriority, rhs: Float) -> UILayoutPriority {
        let newRawValue = min(UILayoutPriority.required.rawValue, max(1, lhs.rawValue + rhs))
        return UILayoutPriority(rawValue: newRawValue)
    }
    
    static func - (lhs: UILayoutPriority, rhs: Float) -> UILayoutPriority {
        let newRawValue = max(1, min(UILayoutPriority.required.rawValue, lhs.rawValue - rhs))
        return UILayoutPriority(rawValue: newRawValue)
    }
}

With the previous extension in place, we’re allowed to manipulate layout priorities in a more natural way, like this:

// Swift 4
let heightConstraint = progressView.heightAnchor.constraint(equalTo: heightAnchor)
heightConstraint.priority = .defaultLow + 10

Handling different Swift versions for each Pod

At Runtastic, we use CocoaPods dependency manager for our shared components. If you happen to have both Pods that are only compatible with Swift 3 as well as ones that should be compiled with Swift 4, you might consider using a post-install hook in your Podfile. Here’s a snippet that sets the Swift language version to 3.2 for all Pod targets except the ones matching the if-condition.

post_install do |installer|
    installer.pods_project.targets.each do |target|
        target.build_configurations.each do |config|
            if !(target.name == '< pod that should be on Swift 4 >')
                config.build_settings['SWIFT_VERSION'] = '3.2'
            end
        end
    end 
end

Wrapping up

Looking back, we’d probably agree that migrating all our core apps was far from a trivial task, though definitely not as daunting as the move to Swift 3 a year ago. Doing it this early gives us time to try out and experiment with the new features, and we get the extra perk that we are no longer writing what would quickly become legacy code.

To all of you out there who are planning the move to Swift 4, a warm:

– Happy migrations!

***

Tech Team We are made up of all the tech departments at Runtastic like iOS, Android, Backend, Infrastructure, DataEngineering, etc. We’re eager to tell you how we work and what we have learned along the way. View all posts by Tech Team