iOS开发-重构维基百科的 iOS 应用

2021-10-15  本文已影响0人  iOS丶lant

本案例研究描述了 WikipediaWMFLocationManager类从 Objective-C 到 Swift的重构。

维基百科原生 iOS 应用程序项目于 2013 年启动。近 7 年和 30,000 次提交之后,该项目包含超过 180,000 行各种语言的代码。Swift 和 Objective-C 代码之间的比例大约是 2:1。

因为整个应用程序在 GitHub 上是开源的,所以它是展示真实世界的 Objective-C 到 Swift 重构和清理的理想选择。

挑战

我们将 Wikipedia 的WMFLocationManager类从 Objective-C重构为 Swift。以下是有关该类的一些基本事实:

重构包括以下步骤:

  1. 删除未使用的代码
  2. 使当前的实现可测试
  3. 向当前实现添加测试
  4. 编写 Swift 实现并使用测试对其进行验证
  5. 交换实现
以地图和列表模式放置屏幕

1. 删除未使用的代码

旧代码库包含大量未使用的代码是很常见的。随着代码的变化和发展,许多 API 变得过时或被遗忘。重构是清理 API 和删除死代码的理想时机。

您可以在以下提交中看到清理:

删除不必要的代码后,我们只剩下 290 行代码。我们需要重构的代码减少了 40 行;少了 40 行可能会破坏和包含错误的代码。

2. 使当前的实现可测试

影响给定代码段的可测试性的因素有很多。一般来说,正确测试以下元素更困难(有时甚至不可能):

2.1 依赖注入

WMFLocationManager取决于 的一个实例CLLocationManager。它还访问UIDevice.current单例。但是,没有办法注入这些依赖项的模拟版本,这将允许我们模拟系统行为并观察由此产生的变化。

WMFLocationManager 没有可公开访问的初始化程序,只有两个工厂方法:

+ (instancetype)fineLocationManager;

+ (instancetype)coarseLocationManager;

但是,采用CLLocationManager实例的初始化程序已经存在,只是不是公开的。我们想让它从测试中访问,而不是从生产目标中访问。我们向 中添加了一个新(Testing)类别WMFLocationManager,它公开了这个初始值设定项。此类别仅包含在测试目标中,而不包含在生产目标中。我们还扩展了初始化器以获取UIDevice参数。


提交:添加 WMFLocationManager 测试类别

的另一个完全隐藏的依赖项WMFLocationManagerCLGeocoder。它用于在reverseGeocodeLocation方法中执行反向地理编码。

- (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);
        }
    }];
}

它是 的实例方法WMFLocationManager,但是当您仔细观察时,您会发现它与它无关。它只是围绕CLGeocoder. 它也很容易成为静态或独立的功能。

关于这种方法的另一个有趣的事实是它只在一个地方使用:WMFNearbyContentSource. 我们决定将整个反向地理编码功能移到WMFNearbyContentSource实际使用的位置(提交:将反向地理编码移出 LocationManager)。

注意:这只是一个临时解决方案。当有人决定重构时WMFNearbyContentSource,他们需要将功能提取到适当的可注入依赖项中并对其进行测试。

2.2 类方法

WMFLocationManager 公开了几个描述当前授权状态的类方法。

 + (BOOL)isAuthorized;

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

在原始实现的上下文中,这个决定是有道理的。CLLocationManagerauthorizationStatus以类方法的形式公开它。这些类方法存在一些缺点:

例如:您会LocationManager在 a 中创建一个实例CollectionViewCell吗?可能不会……但是只要调用一个类方法就这么简单:

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

(在提交中修复:从单元格中移动LocationManager.isAuthorized支票

生成的代码更具表现力,并使依赖关系清晰(提交:将WMFLocationManager 类函数更改为实例函数

例如,某些[WMFLocationManager isAuthorized]调用更改为[self isAuthorized],其他调用更改为[self.locationManager isAuthorized]

最后,我们必须将所有硬编码的CLLocationManager类方法调用替换为符合当前位置管理器类型的调用。(提交:动态获取 CLLocationManager 类型

如果你正在跳槽或者正准备跳槽不妨动动小手,添加一下咱们的交流群1012951431来获取一份详细的大厂面试资料为你的跳槽多添一份保障。

3. 在当前实现中添加测试

用测试覆盖现有功能是大多数重构的关键部分。然后使用该测试套件来验证新实现的行为,该行为应该与原始实现的行为等效。

在最好的情况下,应该可以仅使用其测试来重构给定的代码段。对于非 UI 代码库,这通常更容易,例如WMFLocationManager我们重构的代码库。不幸的是,在重构 UI 相关的代码、动画等时,通常不可能完全使用这种技术。

3.1 模拟

为了控制 的输入WMFLocationManager,我们必须创建几个模拟。

例如,以下CLLocationManager子类允许我们模拟管理器回调或提供特定CLAuthorizationStatus值:

/// 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])
    }

    // … //
}

类似的方法用于控制 的headingAccuracyCLHeading,默认情况下它是只读的:

/// 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()
    }
    //..//
}

我们还创建了一个模拟类型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 测试 LocationManagerDelegate

为了轻松测试WMFLocationManagerDelegate回调,我们创建了协议的具体实现,它记录了回调中的值:

/// 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 测试

我们创建的测试套件的主要目标是充分覆盖所有公共 APIWMFLocationManager并对其功能做出强有力的断言。

例如,这是我们测试内部正确精度设置的方式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)
    }

    // .. //
}

授权状态测试:

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)

        // ... //
    }
}

(提交:添加基本的 LocationManager 测试

最后是位置更新机制的测试,包括WMFLocationManager委托回调:

func testUpdateLocation() {
    locationManager.startMonitoringLocation()

    let location = CLLocation(latitude: 10, longitude: 20)
    mockCLLocationManager.simulateUpdate(location: location)

    XCTAssertEqual(locationManager.location, location)
    XCTAssertEqual(delegate.location, location)
}

(提交:添加设备方向 LocationManager 测试

如果你正在跳槽或者正准备跳槽不妨动动小手,添加一下咱们的交流群1012951431来获取一份详细的大厂面试资料为你的跳槽多添一份保障。

4. 编写 Swift 实现

4.1 调整测试套件

我们做的第一件事是创建新 Swift 位置管理器的骨架版本。我们定义了它的公共 API,在需要的地方添加了虚拟实现,并调整了我们在上一步中创建的测试套件以使用新的 Swift 版本。

在这一点上,几乎所有的测试都失败了。两次绿色测试的预期状态与默认状态相同(即停止位置监控)。当我们修复其他测试时,这些测试开始“正常”失败,比如开始监控的测试。

提交:添加 LocationManager 骨架并更新 LocationManagerTests

4.2 添加实现

我们一一添加了使测试变绿所需的所有实现。因为我们使用了这种方法,所以我们不必1:1复制原始实现,并且能够简化和清理类的内部结构。

提交:实现 LocationManager 功能

在我们之前编写测试套件时,我们故意遗漏了一些原始WMFLocationManager功能。例如,我们没有为调试日志添加测试(提交:将日志添加到 LocationManager)。我们也没有测试kCLErrorLocationUnknown在模拟器中运行时抑制错误。(承诺:在模拟器中不要传播位置错误

因为这些功能不是生产代码库的一部分,所以我们很乐意将它们排除在测试套件之外。然而,我们不想失去原始功能的任何东西,所以我们重新添加了它们,即使它们的实现不是由失败的测试驱动的。

5. 交换实现

5.1 添加 Objective-C 兼容性

原始WMFLocationManager代码用于代码库的 Swift 和 Objective-C 部分。

解决此问题的常用方法是使用@objc属性标记类及其公共 API 。然而,这会阻止我们使用 Swift 独有的特性,比如结构和丰富的枚举,这些特性不能在 Objective-C 中表示。

此外,代码库的 Objective-C 部分正在慢慢重构为 Swift,因此让它过多地影响新重构的 API 有点短视。

@objc我们决定创建一个新的 Objective-C 可表示协议来描述 LocationManager 的公共 API,而不是标记整个类,并让 Swift LocationManager 遵守它:

@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 无法访问 Swift 枚举的扩展,因此它无法访问CLAuthorizationStatus isAuthorized在扩展中实现的值。我们必须提供桥接代码以允许 Objective-CLocationManager正确使用:

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

因为 Swift 实现struct在其初始值设定项中使用了 a ,所以我们将其调用包装在一个 Objective-C 友好的工厂方法中:

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

通过这样做,我们在能够使用仅限 Swift 的功能的同时实现了 Objective-C 兼容性。

LocationManagerProtocolLocationManager从 Objective-C 停止使用时,上面的和 工厂方法都将被删除。理论上,当不再需要 Objective-C 兼容性时,应该可以只恢复对 LocationManager提交的Add ObjC 支持

5.2 交换实现

在代码库的 Swift 部分,我们能够简单地将WMFLocationManager调用替换为LocationManager,因为我们没有显着更改公共 API。

在 Objective-C 部分,我们用前面提到的WMFLocationManager等价协议替换了具体类型id<LocationManagerProtocol>

在确保新实现正常工作后,我们终于能够删除WMFLocationManagerObjective-C 实现及其相关文件。(提交:删除 WMFLocationManager


应用程序的“地点”和“地点附近”屏幕。

概括

在这次重构中,我们:

这是一个总结重构前后位置管理器代码库状态的表格:

LocationManager 的 Swift 版本的生产代码行数减少了大约 40%。这部分是因为 Swift 的语法更加简洁。

真正改变的是类的整体可维护性。LocationManager 的实现使用了 Swift,对 iOS 新手程序员更加友好。它的功能由测试覆盖,这使得类的未来更改和重构更加容易和安全。

您可以在此处查看此重构中所有提交的 PR。

文末推荐:iOS热门文集

上一篇 下一篇

猜你喜欢

热点阅读