本案例研究描述了 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。以下是有关该类的一些基本事实:
- 包装 CLLocationManager 并处理其回调
- 观察当前设备方向并相应地更新航向信息
- 执行反向地理编码
- 有大约 330 行实现代码
- 在 Objective-C 和 Swift 中都使用
- 未测试
重构包括以下步骤:
- 删除未使用的代码
- 使当前的实现可测试
- 向当前实现添加测试
- 编写 Swift 实现并使用测试对其进行验证
- 交换实现

1. 删除未使用的代码
旧代码库包含大量未使用的代码是很常见的。随着代码的变化和发展,许多 API 变得过时或被遗忘。重构是清理 API 和删除死代码的理想时机。
您可以在以下提交中看到清理:
- 删除未使用的 WMFMockLocationManager
- 删除未使用的属性
significantLocationUpdatesOnly
- 删除未使用的方法
restartLocationMonitoring
- 将非委托代码移出委托扩展
删除不必要的代码后,我们只剩下 290 行代码。我们需要重构的代码减少了 40 行;少了 40 行可能会破坏和包含错误的代码。
2. 使当前的实现可测试
影响给定代码段的可测试性的因素有很多。一般来说,正确测试以下元素更困难(有时甚至不可能):
-
违反单一职责原则。示例:如果一个函数或一个对象有太多的职责,就很难测试所有可能的输入和输出组合。正确覆盖功能所需的测试用例的数量通常与 n 2 成比例。
-
有隐藏的依赖、输入和副作用。示例:如果一个对象秘密地从数据库中存储和加载数据,它会影响结果。如果函数秘密执行网络调用,则当网络关闭时测试可能会失败。
-
有不可预测的行为。例子:如果一个函数的结果是基于未知条件的,很明显它是无法预测的。
2.1 依赖注入
WMFLocationManager
取决于 的一个实例CLLocationManager
。它还访问UIDevice.current
单例。但是,没有办法注入这些依赖项的模拟版本,这将允许我们模拟系统行为并观察由此产生的变化。
WMFLocationManager
没有可公开访问的初始化程序,只有两个工厂方法:
+ (instancetype)fineLocationManager;
+ (instancetype)coarseLocationManager;
但是,采用CLLocationManager
实例的初始化程序已经存在,只是不是公开的。我们想让它从测试中访问,而不是从生产目标中访问。我们向 中添加了一个新(Testing)
类别WMFLocationManager
,它公开了这个初始值设定项。此类别仅包含在测试目标中,而不包含在生产目标中。我们还扩展了初始化器以获取UIDevice
参数。

提交:添加 WMFLocationManager 测试类别
的另一个完全隐藏的依赖项WMFLocationManager
是CLGeocoder
。它用于在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;
在原始实现的上下文中,这个决定是有道理的。CLLocationManager
还authorizationStatus
以类方法的形式公开它。这些类方法存在一些缺点:
- 像这样的代码更难测试。尽管有多种方法可以使用 Objective-C 运行时来模拟和控制类方法的行为,但这并不是我们在 2020 年想要做的事情。
- **滥用它太容易了。 **调用类方法的简单性
LocationManager.isAuthorized
也是它最大的问题。要调用实例方法,首先需要有一个实例。当它不存在时,您需要创建它并将其存储在某处。创建新对象涉及许多步骤,它们都迫使您质疑最初的决定。
例如:您会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])
}
// … //
}
类似的方法用于控制 的headingAccuracy
值CLHeading
,默认情况下它是只读的:
/// 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)
// ... //
}
}
最后是位置更新机制的测试,包括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复制原始实现,并且能够简化和清理类的内部结构。

在我们之前编写测试套件时,我们故意遗漏了一些原始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 兼容性。
LocationManagerProtocol
当LocationManager
从 Objective-C 停止使用时,上面的和 工厂方法都将被删除。理论上,当不再需要 Objective-C 兼容性时,应该可以只恢复对 LocationManager提交的Add ObjC 支持。
5.2 交换实现
在代码库的 Swift 部分,我们能够简单地将WMFLocationManager
调用替换为LocationManager
,因为我们没有显着更改公共 API。
在 Objective-C 部分,我们用前面提到的WMFLocationManager
等价协议替换了具体类型id<LocationManagerProtocol>
。
在确保新实现正常工作后,我们终于能够删除WMFLocationManager
Objective-C 实现及其相关文件。(提交:删除 WMFLocationManager)

应用程序的“地点”和“地点附近”屏幕。
概括
在这次重构中,我们:
- 删除了 101 行未使用的代码
- 添加了 312 行测试
- 添加了 3 个可重用的模拟
- 添加了 13 个不同的测试
这是一个总结重构前后位置管理器代码库状态的表格:

LocationManager 的 Swift 版本的生产代码行数减少了大约 40%。这部分是因为 Swift 的语法更加简洁。
真正改变的是类的整体可维护性。LocationManager 的实现使用了 Swift,对 iOS 新手程序员更加友好。它的功能由测试覆盖,这使得类的未来更改和重构更加容易和安全。
文末推荐:iOS热门文集
- 面试基础
iOS面试基础知识 (一)
https://github.com/iOS-Mayday/heji
iOS面试基础知识 (二)
https://github.com/iOS-Mayday/heji
iOS面试基础知识 (三)
https://github.com/iOS-Mayday/heji
iOS面试基础知识 (四)
https://github.com/iOS-Mayday/heji
iOS面试基础知识 (五)
https://github.com/iOS-Mayday/heji
网友评论