我们把请求DarkSky的代码封装起来,以降低这部分代码在未来对我们App的影响。并为这部分的单元测试,做一些准备工作。
设计DataManager
为了封装DarkSky的请求,我们在Sky中新建一个分组:Manager,并在其中添加一个WeatherDataManager.swif文件。在这里,我们创建一个class WeatherDataManager
来管理对DarkSky
的请求:
final class WeatherDataManager { }
这里,由于WeatherDataManager
不会作为其它类的基类,我们在声明中使用了final
关键字,可以提高这个对象的访问性能。
WeatherDataManager
有一个属性,表示请求的URL:
final class WeatherDataManager {
private let baseURL: URL
}
然后,我们用下面的代码创建一个单例,便于我们用一致的方式请求天气数据:
final class WeatherDataManager {
private let baseURL: URL
private init(baseURL: URL) {
self.baseURL = baseURL
}
static let shared =
WeatherDataManager(API.authenticatedUrl)
}
这样,我们就只能通过WeatherDataManager.shared
这样的形式,来访问WeatherDataManager
对象了。
接下来,我们要在WeatherDataManager
中创建一个根据地理位置返回天气信息的方法。由于网络请求是异步的,这个过程只能通过回调函数完成。因此,这个方法看上去应该是这样的:
final class WeatherDataManager {
// ...
typealias CompletionHandler =
(WeatherData?, DataManagerError?) -> Void
func weatherDataAt(
latitude: Double,
longitude: Double,
completion: @escaping CompletionHandler) {}
}
然后,我们来定义获取数据时的错误:
enum DataManagerError: Error {
case failedRequest
case invalidResponse
case unknown
}
简单起见,我们只定义了三种情况:非法请求、非法返回以及未知错误。然后,我们来实现weatherAt
方法,它的逻辑很简单,只是按约定拼接URL,设置HTTP header,然后使用URLSession
发起请求就好了:
func weatherDataAt(latitude: Double,
longitude: Double,
completion: @escaping CompletionHandler) {
// 1\. Concatenate the URL
let url = baseURL.appendingPathComponent("\(latitude), \(longitude)")
var request = URLRequest(url: url)
// 2\. Set HTTP header
request.setValue("application/json",
forHTTPHeaderField: "Content-Type")
request.httpMethod = "GET"
// 3\. Launch the request
URLSession.shared.dataTask(
with: request, completionHandler: {
(data, response, error) in
// 4\. Get the response here
}).resume()
}
在dataTask
的completionHandler
中,为了让代码看上去干净一些,我们只调用一个帮助函数:
URLSession.shared.dataTask(with: request,
completionHandler: {
(data, response, error) in
DispatchQueue.main.async {
self.didFinishGettingWeatherData(
data: data,
response: response,
error: error,
completion: completion)
}
}).resume()
这里,为了保证可以在dataTask
的回调函数中更新UI,我们把它派发到主线程队列执行。完成后,我们来实现didFinishGettingWeatherData
:
func didFinishGettingWeatherData(
data: Data?,
response: URLResponse?,
error: Error?,
completion: CompletionHandler) {
if let _ = error {
completion(nil, .failedRequest)
}
else if let data = data,
let response = response as? HTTPURLResponse {
if response.statusCode == 200 {
do {
let weatherData =
try JSONDecoder().decode(WeatherData.self, from: data)
completion(weatherData, nil)
}
catch {
completion(nil, .invalidResponse)
}
}
else {
completion(nil, .failedRequest)
}
}
else {
completion(nil, .unknown)
}
}
其实逻辑很简单,就是根据请求以及服务器的返回值是否可用,把对应的参数传递给了一个可以自定义的回调函数。这样,这个WeatherDataManager
就实现好了。
现在,回想起来,我们在这两节中,关于model的部分,已经写了不少的代码了,它们真的能正常工作么?我们如何确定这个事情呢?在把model关联到controller之前,我们最好确定一下。
当然,一个直观的办法就是在类似某个viewDidLoad
之类的方法里,写个代码实际请求一下看看。但是估计你也能感觉到这种做法并不地道,如果未来你修改了Manager
的代码呢?难道还要重新找个viewDidLoad
方法插个空来测试么?估计你自己都不太敢这样做,万一你在恢复的时候不慎修改掉了哪部分功能代码,就很容易随随便便坑上你几个小时。
为此,我们需要一种更专业和安全的方式,来确定局部代码的正确性。这种方式,就是单元测试。在开始测试我们的WeatherDataManager
之前,我们要先了解一下Xcode提供的单元测试模板。
了解单元测试模板
首先,在Xcode默认创建的SkyTests分组中,删掉默认的SkyTests.swift。然后在SkyTests Group上点右键,选择New File...:
其次,在右上角的filter中,输入unit,找到单元测试的模板。选中Unit Test Case Class,点击Next:
第三,给测试用例起个名字,例如WeatherDataManagerTest。这个名字最好可以直接表达我们要测试的内容。这样,不同的开发者都可以方便的了解到实际测试的内容:
第四,接下来,Xcode就会提示我们是否需要创建一个bridge header,由于我们在纯Swift环境中开发,因此,选择Don't Create,并点击Finish按钮。
设置好保存路径之后,我们就可以在SkyTests
分组中,找到新添加的测试用例了。在开始编写测试之前,这个文件中有几个值得说明的地方:
首先,在文件一开始,要添加下面的代码引入项目的main module。这样,才能在测试用例中,访问到项目定义的类型:
import XCTest
@testable import Sky
其次,在生成的代码中,WeatherDataManagerTest
派生自XCTestCase
,表示这是一个测试用例。
第三,在WeatherDataManagerTest
里,我们可以把所有的测试前要准备的代码,写在setUp
方法里,而把测试后需要清理的代码,写在tearDown
方法里。这里要注意下面代码中注释的位置,初始化代码写在super.setUp()
后面,清理代码要写在super.tearDown()
前面:
class WeatherDataManagerTest: XCTestCase {
override func setUp() {
super.setUp()
// Your set up code here...
}
override func tearDown() {
// Your tear down code here...
super.tearDown()
}
// ...
}
第四,Xcode为我们生成了两个默认的测试方法:
class WeatherDataManagerTest: XCTestCase {
func testExample() {
// ...
}
func testPerformanceExample() {
// ...
}
}
要注意的是,所有测试方法都必须用test
开头,Xcode才会识别它们并自动执行。这里,可以先把它们删掉,稍后我们会编写自己的测试方法。