This case study describes the refactoring of Wikipedia’s WMFLocationManager class from Objective-C to Swift.

The Wikipedia native iOS app project was launched in 2013. Almost 7 years and 30,000 commits later, the project consists of more than 180,000 lines of code in various languages. The ratio between Swift and Objective-C code is roughly 2:1.

Because the whole app is open-sourced on GitHub, it’s an ideal candidate for showcasing a real-world Objective-C to Swift refactoring and cleanup.

Challenges

We refactored Wikipedia’s WMFLocationManager class from Objective-C to Swift. Here are some basic facts about the class:

  • Wraps CLLocationManager and handles its callbacks
  • Observes the current device orientation and updates the heading info accordingly
  • Performs reversed geocoding
  • Has around 330 lines of implementation code
  • Is used both from Objective-C and Swift
  • Is not tested

The refactoring included the following steps:

  1. Removing unused code
  2. Making the current implementation testable
  3. Adding tests to the current implementation
  4. Writing Swift implementation and validating it using the tests
  5. Swapping the implementations
Places screen in map and list mode.

1. Removing unused code

It’s very common for older codebases to contain a lot of unused code. As the code changes and evolves, many APIs become obsolete or simply forgotten. Refactoring is the ideal occasion to clean up the APIs and delete dead code.

You can see the cleanups in the following commits:

After deleting the unnecessary code, we were left with 290 lines of code. That’s 40 lines of code less we needed to refactor; 40 lines of code less that could break and contain bugs.

2. Making the current implementation testable

There are many factors affecting the testability of a given piece of code. In general, it’s more difficult (and sometimes impossible) to properly test elements which:

  • Violate the Single Responsibility Principle. Example: If a function or an object has too many responsibilities, it’s hard to test all the possible combinations of inputs and outputs. The number of test cases needed to properly cover the functionality often scales with n2.
  • Have hidden dependencies, inputs and side effects. Example: If an object secretly stores and loads data from a database, it can affect the results. If a function secretly performs a network call, the test can fail when the network is down.
  • Have unpredictable behavior. Example: If the result of a function is based on unknown conditions, it’s obvious that it can’t be predicted.

2.1 Dependency injection

WMFLocationManager depends on an instance of CLLocationManager. It also accesses the UIDevice.current singleton. However, there wasn’t a way to inject mock versions of these dependencies, which would allow us to simulate the system behavior and observe the resulting changes.

WMFLocationManager had no publicly accessible initializer, only two factory methods:

+ (instancetype)fineLocationManager;
 
+ (instancetype)coarseLocationManager;

However, the initializer taking a CLLocationManager instance already existed, it just wasn’t public. We wanted to make it accessible from tests but not from the production target. We added a new (Testing) category to WMFLocationManager, which exposes this initializer. This category is included only in the test target, not in the production target. We also extended the initializer to take the UIDevice parameter, too.

commit: Add WMFLocationManager testing category

Another, completely hidden, dependency of WMFLocationManager is CLGeocoder. It’s used for performing reverse geocoding in the reverseGeocodeLocation method.

- (void)reverseGeocodeLocation:(CLLocation *)location completion:(void (^)(CLPlacemark *placemark))completion
                       failure:(void (^)(NSError *error))failure {
    [[[CLGeocoder alloc] init] reverseGeocodeLocation:location
                                    completionHandler:^(NSArray<CLPlacemark *> *_Nullable placemarks, NSError *_Nullable error) {
        if (failure && error) {
            failure(error);
        } else if (completion) {
            completion(placemarks.firstObject);
        }
    }];
}

It’s an instance method of WMFLocationManager, but when you look closely, you’ll see it has nothing to do with it. It’s just a wrapper method around CLGeocoder. It could easily be a static or free-standing function, too.

Another interesting fact about this method is that it’s used just in one place: WMFNearbyContentSource. We decided to move the whole reverse geocoding functionality to WMFNearbyContentSource, where it’s actually used (commit: Move reverse geocoding out of LocationManager).

Note: This is just a temporary solution. When someone decides to refactor WMFNearbyContentSource, they will need to extract the functionality into a proper injectable dependency and test it.

2.2 Class methods

WMFLocationManager exposes a couple of class methods describing the current authorization status.

 + (BOOL)isAuthorized;
 
 + (BOOL)isAuthorizationNotDetermined;
 + (BOOL)isAuthorizationDenied;
 + (BOOL)isAuthorizationRestricted;

In the context of the original implementation, this decision makes sense. CLLocationManager also exposes its authorizationStatus in the form of a class method. These class methods present a couple of drawbacks:

  • Code like this is harder to test. Even though there are ways to mock and control the behavior of class methods using the Objective-C runtime, it was not something we wanted to do in 2020.
  • It’s too easy to misuse it.  The simplicity of calling class methods like LocationManager.isAuthorized is also its biggest problem. To call an instance method, you first need to have an instance. When it doesn’t exist, you need to create it and store it somewhere. There are many steps involved in creating a new object, and they all force you to question the original decision.

For example: Would you create a LocationManager instance in a CollectionViewCell? Probably not… But it’s so easy to just make a class method call:

class ArticleLocationAuthorizationCollectionViewCell: ArticleLocationExploreCollectionViewCell {
    // ... //
    public func updateForLocationEnabled() {
        guard WMFLocationManager.isAuthorized() else {
            return
        }
        // ... //
    }
}

(Fixed in the commit: Move the LocationManager.isAuthorized check from the cell)

The resulting code is more expressive and makes the dependencies clear (commit: Change WMFLocationManager class functions to instance functions)

For example, some [WMFLocationManager isAuthorized] calls were changed to [self isAuthorized], others were changed to [self.locationManager isAuthorized].

Finally, we had to replace all hardcoded CLLocationManager class method calls with the ones that respect the current type of location manager. (commit: Get CLLocationManager type dynamically)

3. Adding tests to the current implementation

Covering the existing functionality with tests is a crucial part of most of the refactorings. This test suite is then used to verify the behavior of the new implementation, which should be equivalent to the behavior of the original.

In the best-case scenario, it should be possible to refactor a given piece of code just using its tests. This is often easier for a non-UI codebase, like the WMFLocationManager we refactored. Unfortunately, it’s usually not possible to fully use this technique when refactoring UI-related code, animations, etc.

3.1 Mocks

To control the inputs of WMFLocationManager, we had to create several mocks.

For example, the following CLLocationManager subclass allows us to simulate the manager callbacks or provide a specific CLAuthorizationStatus value:

/// A `CLLocationManager` subclass allowing mocking in tests.
final class MockCLLocationManager: CLLocationManager {

    private var _location: CLLocation?
    override var location: CLLocation? { _location }

    private static var _authorizationStatus: CLAuthorizationStatus = .authorizedAlways
    override class func authorizationStatus() -> CLAuthorizationStatus {
        return _authorizationStatus
    }

    /// Simulates a new location being emitted. Updates the `location` property
    /// and notifies the delegate.
    ///
    /// - Parameter location: The new location used.
    ///
    func simulateUpdate(location: CLLocation) {
        _location = location
        delegate?.locationManager?(self, didUpdateLocations: [location])
    }

    // … //
}

A similar approach is used for controlling the headingAccuracy value of CLHeading, which is by default read-only:

/// A `CLHeading` subclass allowing modification of the `headingAccuracy` value.
final class MockCLHeading: CLHeading {
  
    var _headingAccuracy: CLLocationDirection
    override var headingAccuracy: CLLocationDirection { _headingAccuracy }

    init(headingAccuracy: CLLocationDirection) {
        _headingAccuracy = headingAccuracy
        super.init()
    }
	//..//
}

We also create a mock type for UIDevice:

/// A `UIDevice` subclass allowing mocking in tests.
final class MockUIDevice: UIDevice {

    private var _orientation: UIDeviceOrientation
    override var orientation: UIDeviceOrientation {
        return _orientation
    }

    var beginGeneratingDeviceOrientationCount: Int = 0
    override func beginGeneratingDeviceOrientationNotifications() {
        super.beginGeneratingDeviceOrientationNotifications()
        beginGeneratingDeviceOrientationCount += 1
    }

    // .. //

    init(orientation: UIDeviceOrientation) {
        _orientation = orientation
    }

    /// Simulates changing the device orientation. Updates the `orientation` variable and
    /// posts the `UIDevice.orientationDidChangeNotification` notification.
    ///
    /// - Parameter orientation: The new orientation value.
    ///
    func simulateUpdate(orientation: UIDeviceOrientation) {
        _orientation = orientation

        NotificationCenter.default.post(
            name: UIDevice.orientationDidChangeNotification,
            object: self
        )
    }
}

3.2 Testing LocationManagerDelegate

To easily test WMFLocationManagerDelegate callbacks, we created a concrete implementation of the protocol, which records the values from its callbacks:

/// A test implementation of `LocationManagerDelegate`.
private final class TestLocationManagerDelegate: LocationManagerDelegate {
    private(set) var heading: CLHeading?
    private(set) var location: CLLocation?
    private(set) var error: Error?
    private(set) var authorized: Bool?

    func locationManager(_ locationManager: LocationManagerProtocol, didReceive error: Error) {
        self.error = error
    }

    func locationManager(_ locationManager: LocationManagerProtocol, didUpdate heading: CLHeading) {
        self.heading = heading
    }

    func locationManager(_ locationManager: LocationManagerProtocol, didUpdate location: CLLocation) {
        self.location = location
    }

    func locationManager(_ locationManager: LocationManagerProtocol, didUpdateAuthorized authorized: Bool) {
        self.authorized = authorized
    }
}

3.3 Tests

The main goal of the test suite we created was to sufficiently cover all public APIs of WMFLocationManager and make strong assertions about its functionality.

For example, this is how we tested the proper accuracy settings of the inner CLLocationManager:

final class LocationManagerTests: XCTestCase {

    // ... //

    func testFineLocationManager() {
        let locationManager = WMFLocationManager.fine()
        XCTAssertEqual(locationManager.locationManager.distanceFilter, 1)
        XCTAssertEqual(locationManager.locationManager.desiredAccuracy, kCLLocationAccuracyBest)
        XCTAssertEqual(locationManager.locationManager.activityType, .fitness)
    }

    // .. //
}

Authorization status tests:

final class LocationManagerTests: XCTestCase {

    private var mockCLLocationManager: MockCLLocationManager!
    private var locationManager: WMFLocationManager!
    private var delegate: TestLocationManagerDelegate!

    override func setUp() {
        super.setUp()

        mockCLLocationManager = MockCLLocationManager()
        mockCLLocationManager.simulate(authorizationStatus: .authorizedAlways)

        locationManager = WMFLocationManager(locationManager: mockCLLocationManager)

        delegate = TestLocationManagerDelegate()
        locationManager.delegate = delegate
    }

    // ... //

    func testAuthorizedStatus() {
        // Test authorizedAlways status.
        mockCLLocationManager.simulate(authorizationStatus: .authorizedAlways)
        XCTAssertEqual(locationManager.isAuthorized(), true)
        XCTAssertEqual(locationManager.isAuthorizationNotDetermined(), false)
        XCTAssertEqual(locationManager.isAuthorizationDenied(), false)
        XCTAssertEqual(locationManager.isAuthorizationRestricted(), false)

        // Test notDetermined status.
        mockCLLocationManager.simulate(authorizationStatus: .notDetermined)
        XCTAssertEqual(locationManager.isAuthorized(), false)
        XCTAssertEqual(locationManager.isAuthorizationNotDetermined(), true)
        XCTAssertEqual(locationManager.isAuthorizationDenied(), false)
        XCTAssertEqual(locationManager.isAuthorizationRestricted(), false)

        // ... //
    }
}

(Commit: Add basic LocationManager tests)

And finally the tests for the location update mechanism, including the WMFLocationManager delegate callbacks:

func testUpdateLocation() {
    locationManager.startMonitoringLocation()

    let location = CLLocation(latitude: 10, longitude: 20)
    mockCLLocationManager.simulateUpdate(location: location)
        
    XCTAssertEqual(locationManager.location, location)
    XCTAssertEqual(delegate.location, location)
}

(Commit: Add device orientation LocationManager tests)

4. Writing a Swift implementation

4.1 Adjusting the test suite

The first thing we did was to create a skeleton version of the new Swift location manager. We defined its public API, added dummy implementations where needed, and adjusted the tests suite we created in the previous step to work with the new Swift version. 

At this point, almost all tests were failing. The expected state of the two green tests was the same as the default state (i.e. the location monitoring is stopped). These tests started “properly” failing when we fixed other tests, like the one for starting the monitoring.

commit: Add LocationManager skeleton and update LocationManagerTests

4.2 Adding the implementation

We added all the implementation needed to make the tests green one by one. Because we used this approach, we didn’t have to copy the original implementation 1:1 and were able to simplify and clean up the inner structure of the class.

commit: Implement LocationManager functionality

When we wrote the test suite earlier, we deliberately left out some of the original WMFLocationManager functionality. For example, we didn’t add tests for the debug logs (commit: Add logs to LocationManager). We also didn’t test suppressing the kCLErrorLocationUnknown error when running in the simulator. (commit: Don’t propagate location errors when in the simulator)

Because these features are not part of the production codebase, we felt comfortable leaving them out of the test suite. However, we didn’t want to lose anything from the original functionality, so we added them back, even though their implementation was not driven by failing tests.

5. Swapping the implementations

5.1 Adding Objective-C compatibility

The original WMFLocationManager was used in both the Swift and Objective-C parts of the codebase.

A common approach to this problem is to mark the class and its public API with the @objc attribute. However, this would prevent us from using Swift-only features, like structs and rich enums, which can’t be represented in Objective-C.

Also, the Objective-C part of the codebase is slowly getting refactored to Swift, so it would be a little shortsighted to let it influence the freshly refactored API too much.

Instead of marking the whole class @objc we decided to create a new Objective-C-representable protocol describing LocationManager’s public API and let the Swift LocationManager conform to it:

@objc public protocol LocationManagerProtocol {
    /// Last know location
    var location: CLLocation? { get }
    /// Last know heading
    var heading: CLHeading? { get }
    /// Return `true` in case when monitoring location, in other case return `false`
    var isUpdating: Bool { get }
    /// Delegate for update location manager
    var delegate: LocationManagerDelegate? { get set }
    /// Get current locationManager permission state
    var autorizationStatus: CLAuthorizationStatus { get }
    /// Return `true` if user is aurthorized or authorized always
    var isAuthorized: Bool { get }

    /// Start monitoring location and heading updates.
    func startMonitoringLocation()
    /// Stop monitoring location and heading updates.
    func stopMonitoringLocation()
}

Objective-C can’t access extensions of Swift’s enums, so it can’t access the CLAuthorizationStatus isAuthorized value, which is implemented in an extension. We had to provide bridge code to allow Objective-C use LocationManager properly:

extension LocationManager: LocationManagerProtocol {
    public var isAuthorized: Bool { autorizationStatus.isAuthorized }
}

Because the Swift implementation uses a struct in its initializer, we wrapped its call in an Objective-C-friendly factory method:

@objc final class LocationManagerFactory: NSObject {
    @objc static func coarseLocationManager() -> LocationManagerProtocol {
        return LocationManager(configuration: .coarse)
    }
}

By doing this, we achieved Objective-C compatibility while having the ability to use Swift-only features.

Both the LocationManagerProtocol and the factory method above will be removed when LocationManager stops being used from Objective-C. In theory, it should be possible to just revert the Add ObjC support for LocationManager commit when the Objective-C compatibility is no longer needed.

5.2 Swapping the implementations

In the Swift part of the codebase, we were able to simply replace the WMFLocationManager calls with LocationManager, because we didn’t significantly change the public API.

In the Objective-C part, we replaced the concrete type WMFLocationManager with its protocol equivalent id<LocationManagerProtocol> mentioned earlier.

After making sure the new implementation works correctly, we were finally able to delete the WMFLocationManager Objective-C implementation and its related files. (commit: Remove WMFLocationManager)

“Places” and “Places near” screens of the app.

Summary

During this refactoring we:

  • Removed 101 lines of unused code
  • Added 312 lines of tests
  • Added 3 reusable mocks
  • Added 13 different tests

Here’s a table summarizing the state of the location manager codebase before and after the refactoring:

The Swift version of LocationManager has approximately 40% less lines of production code. This is partly because Swift’s syntax is more condensed.

What really changed is the overall maintainability of the class. LocationManager’s implementation uses Swift, which is more friendly to newcomer iOS programmers. Its functionality is covered by tests, which makes future changes and refactorings of the class more approachable and secure.

You can see the PR with all commits from this refactoring here.

Join our newsletter