版本记录
版本号 | 时间 |
---|---|
V1.0 | 2019.09.16 星期一 |
前言
最近苹果多了一个框架
Combine
,这里我们就一起来看一下这个框架。感兴趣的可以看下面几篇文章。
1. Combine框架详细解析(一) —— 基本概览(一)
2. Combine框架详细解析(二) —— Combine 与MVVM(一)
源码
1. Swift
首先看下工程组织结构
下面就是看源码了
1. Array+Filtering.swift
import Foundation
/// Taken from here: https://stackoverflow.com/a/46354989/491239
public extension Array where Element: Hashable {
static func removeDuplicates(_ elements: [Element]) -> [Element] {
var seen = Set<Element>()
return elements.filter{ seen.insert($0).inserted }
}
}
2. Formatters.swift
import Foundation
let dayFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = "dd"
return formatter
}()
let monthFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = "MMMM"
return formatter
}()
3. Parsing.swift
import Foundation
import Combine
func decode<T: Decodable>(_ data: Data) -> AnyPublisher<T, WeatherError> {
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .secondsSince1970
return Just(data)
.decode(type: T.self, decoder: decoder)
.mapError { error in
.parsing(description: error.localizedDescription)
}
.eraseToAnyPublisher()
}
4. MapView.swift
import SwiftUI
import MapKit
struct MapView: UIViewRepresentable {
private let coordinate: CLLocationCoordinate2D
init(coordinate: CLLocationCoordinate2D) {
self.coordinate = coordinate
}
func makeUIView(context: Context) -> MKMapView {
MKMapView()
}
func updateUIView(_ view: MKMapView, context: Context) {
let span = MKCoordinateSpan(latitudeDelta: 0.05, longitudeDelta: 0.05)
let region = MKCoordinateRegion(center: coordinate, span: span)
let annotation = MKPointAnnotation()
annotation.coordinate = coordinate
view.addAnnotation(annotation)
view.setRegion(region, animated: true)
}
}
5. AppDelegate.swift
import UIKit
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
// MARK: - UISceneSession Lifecycle
func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration {
// Called when a new scene session is being created.
// Use this method to select a configuration to create the new scene with.
return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role)
}
}
6. SceneDelegate.swift
import UIKit
import SwiftUI
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
// Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`.
// If using a storyboard, the `window` property will automatically be initialized and attached to the scene.
// This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead).
guard let windowScene = scene as? UIWindowScene else { return }
let fetcher = WeatherFetcher()
let viewModel = WeeklyWeatherViewModel(weatherFetcher: fetcher)
let weeklyView = WeeklyWeatherView(viewModel: viewModel)
// Use a UIHostingController as window root view controller
let window = UIWindow(windowScene: windowScene)
window.rootViewController = UIHostingController(rootView: weeklyView)
window.makeKeyAndVisible()
self.window = window
}
}
7. WeeklyWeatherView.swift
import SwiftUI
struct WeeklyWeatherView: View {
@ObservedObject var viewModel: WeeklyWeatherViewModel
init(viewModel: WeeklyWeatherViewModel) {
self.viewModel = viewModel
}
var body: some View {
NavigationView {
List {
searchField
if viewModel.dataSource.isEmpty {
emptySection
} else {
cityHourlyWeatherSection
forecastSection
}
}
.listStyle(GroupedListStyle())
.navigationBarTitle("Weather ⛅️")
}
}
}
private extension WeeklyWeatherView {
var searchField: some View {
HStack(alignment: .center) {
TextField("e.g. Cupertino", text: $viewModel.city)
}
}
var forecastSection: some View {
Section {
ForEach(viewModel.dataSource, content: DailyWeatherRow.init(viewModel:))
}
}
var cityHourlyWeatherSection: some View {
Section {
NavigationLink(destination: viewModel.currentWeatherView) {
VStack(alignment: .leading) {
Text(viewModel.city)
Text("Weather today")
.font(.caption)
.foregroundColor(.gray)
}
}
}
}
var emptySection: some View {
Section {
Text("No results")
.foregroundColor(.gray)
}
}
}
8. WeeklyWeatherViewModel.swift
import SwiftUI
import Combine
class WeeklyWeatherViewModel: ObservableObject {
@Published var city: String = ""
@Published var dataSource: [DailyWeatherRowViewModel] = []
private let weatherFetcher: WeatherFetchable
private var disposables = Set<AnyCancellable>()
init(
weatherFetcher: WeatherFetchable,
scheduler: DispatchQueue = DispatchQueue(label: "WeatherViewModel")
) {
self.weatherFetcher = weatherFetcher
_ = $city
.dropFirst(1)
.debounce(for: .seconds(0.5), scheduler: scheduler)
.sink(receiveValue: fetchWeather(forCity:))
}
func fetchWeather(forCity city: String) {
weatherFetcher.weeklyWeatherForecast(forCity: city)
.map { response in
response.list.map(DailyWeatherRowViewModel.init)
}
.map(Array.removeDuplicates)
.receive(on: DispatchQueue.main)
.sink(
receiveCompletion: { [weak self] value in
guard let self = self else { return }
switch value {
case .failure:
self.dataSource = []
case .finished:
break
}
},
receiveValue: { [weak self] forecast in
guard let self = self else { return }
self.dataSource = forecast
})
.store(in: &disposables)
}
}
extension WeeklyWeatherViewModel {
var currentWeatherView: some View {
return WeeklyWeatherBuilder.makeCurrentWeatherView(
withCity: city,
weatherFetcher: weatherFetcher
)
}
}
9. WeeklyWeatherBuilder.swift
import SwiftUI
enum WeeklyWeatherBuilder {
static func makeCurrentWeatherView(
withCity city: String,
weatherFetcher: WeatherFetchable
) -> some View {
let viewModel = CurrentWeatherViewModel(
city: city,
weatherFetcher: weatherFetcher)
return CurrentWeatherView(viewModel: viewModel)
}
}
10. DailyWeatherRow.swift
import SwiftUI
struct DailyWeatherRow: View {
private let viewModel: DailyWeatherRowViewModel
init(viewModel: DailyWeatherRowViewModel) {
self.viewModel = viewModel
}
var body: some View {
HStack {
VStack {
Text("\(viewModel.day)")
Text("\(viewModel.month)")
}
VStack(alignment: .leading) {
Text("\(viewModel.title)")
.font(.body)
Text("\(viewModel.fullDescription)")
.font(.footnote)
}
.padding(.leading, 8)
Spacer()
Text("\(viewModel.temperature)°")
.font(.title)
}
}
}
11. DailyWeatherRowViewModel.swift
import Foundation
import SwiftUI
struct DailyWeatherRowViewModel: Identifiable {
private let item: WeeklyForecastResponse.Item
var id: String {
return day + temperature + title
}
var day: String {
return dayFormatter.string(from: item.date)
}
var month: String {
return monthFormatter.string(from: item.date)
}
var temperature: String {
return String(format: "%.1f", item.main.temp)
}
var title: String {
guard let title = item.weather.first?.main.rawValue else { return "" }
return title
}
var fullDescription: String {
guard let description = item.weather.first?.weatherDescription else { return "" }
return description
}
init(item: WeeklyForecastResponse.Item) {
self.item = item
}
}
// Used to hash on just the day in order to produce a single view model for each
// day when there are multiple items per each day.
extension DailyWeatherRowViewModel: Hashable {
static func == (lhs: DailyWeatherRowViewModel, rhs: DailyWeatherRowViewModel) -> Bool {
return lhs.day == rhs.day
}
func hash(into hasher: inout Hasher) {
hasher.combine(self.day)
}
}
12. CurrentWeatherView.swift
import SwiftUI
struct CurrentWeatherView: View {
@ObservedObject var viewModel: CurrentWeatherViewModel
init(viewModel: CurrentWeatherViewModel) {
self.viewModel = viewModel
}
var body: some View {
List(content: content)
.onAppear(perform: viewModel.refresh)
.navigationBarTitle(viewModel.city)
.listStyle(GroupedListStyle())
}
}
private extension CurrentWeatherView {
func content() -> some View {
if let viewModel = viewModel.dataSource {
return AnyView(details(for: viewModel))
} else {
return AnyView(loading)
}
}
func details(for viewModel: CurrentWeatherRowViewModel) -> some View {
CurrentWeatherRow(viewModel: viewModel)
}
var loading: some View {
Text("Loading \(viewModel.city)'s weather...")
.foregroundColor(.gray)
}
}
13. CurrentWeatherViewModel.swift
import SwiftUI
import Combine
class CurrentWeatherViewModel: ObservableObject {
@Published var dataSource: CurrentWeatherRowViewModel?
let city: String
private let weatherFetcher: WeatherFetchable
private var disposables = Set<AnyCancellable>()
init(city: String, weatherFetcher: WeatherFetchable) {
self.weatherFetcher = weatherFetcher
self.city = city
}
func refresh() {
weatherFetcher
.currentWeatherForecast(forCity: city)
.map(CurrentWeatherRowViewModel.init)
.receive(on: DispatchQueue.main)
.sink(receiveCompletion: { [weak self] value in
guard let self = self else { return }
switch value {
case .failure:
self.dataSource = nil
case .finished:
break
}
}, receiveValue: { [weak self] weather in
guard let self = self else { return }
self.dataSource = weather
})
.store(in: &disposables)
}
}
14. CurrentWeatherRow.swift
import SwiftUI
struct CurrentWeatherRow: View {
private let viewModel: CurrentWeatherRowViewModel
init(viewModel: CurrentWeatherRowViewModel) {
self.viewModel = viewModel
}
var body: some View {
VStack(alignment: .leading) {
MapView(coordinate: viewModel.coordinate)
.cornerRadius(25)
.frame(height: 300)
.disabled(true)
VStack(alignment: .leading) {
HStack {
Text("☀️ Temperature:")
Text("\(viewModel.temperature)°")
.foregroundColor(.gray)
}
HStack {
Text("📈 Max temperature:")
Text("\(viewModel.maxTemperature)°")
.foregroundColor(.gray)
}
HStack {
Text("📉 Min temperature:")
Text("\(viewModel.minTemperature)°")
.foregroundColor(.gray)
}
HStack {
Text("💧 Humidity:")
Text(viewModel.humidity)
.foregroundColor(.gray)
}
}
}
}
}
15. CurrentWeatherRowViewModel.swift
import Foundation
import SwiftUI
import MapKit
struct CurrentWeatherRowViewModel {
private let item: CurrentWeatherForecastResponse
var temperature: String {
return String(format: "%.1f", item.main.temperature)
}
var maxTemperature: String {
return String(format: "%.1f", item.main.maxTemperature)
}
var minTemperature: String {
return String(format: "%.1f", item.main.minTemperature)
}
var humidity: String {
return String(format: "%.1f", item.main.humidity)
}
var coordinate: CLLocationCoordinate2D {
return CLLocationCoordinate2D.init(latitude: item.coord.lat, longitude: item.coord.lon)
}
init(item: CurrentWeatherForecastResponse) {
self.item = item
}
}
16. Responses.swift
import Foundation
struct WeeklyForecastResponse: Codable {
let list: [Item]
struct Item: Codable {
let date: Date
let main: MainClass
let weather: [Weather]
enum CodingKeys: String, CodingKey {
case date = "dt"
case main
case weather
}
}
struct MainClass: Codable {
let temp: Double
}
struct Weather: Codable {
let main: MainEnum
let weatherDescription: String
enum CodingKeys: String, CodingKey {
case main
case weatherDescription = "description"
}
}
enum MainEnum: String, Codable {
case clear = "Clear"
case clouds = "Clouds"
case rain = "Rain"
}
}
struct CurrentWeatherForecastResponse: Decodable {
let coord: Coord
let main: Main
struct Main: Codable {
let temperature: Double
let humidity: Int
let maxTemperature: Double
let minTemperature: Double
enum CodingKeys: String, CodingKey {
case temperature = "temp"
case humidity
case maxTemperature = "temp_max"
case minTemperature = "temp_min"
}
}
struct Coord: Codable {
let lon: Double
let lat: Double
}
}
17. WeatherFetcher.swift
import Foundation
import Combine
protocol WeatherFetchable {
func weeklyWeatherForecast(
forCity city: String
) -> AnyPublisher<WeeklyForecastResponse, WeatherError>
func currentWeatherForecast(
forCity city: String
) -> AnyPublisher<CurrentWeatherForecastResponse, WeatherError>
}
class WeatherFetcher {
private let session: URLSession
init(session: URLSession = .shared) {
self.session = session
}
}
// MARK: - WeatherFetchable
extension WeatherFetcher: WeatherFetchable {
func weeklyWeatherForecast(
forCity city: String
) -> AnyPublisher<WeeklyForecastResponse, WeatherError> {
return forecast(with: makeWeeklyForecastComponents(withCity: city))
}
func currentWeatherForecast(
forCity city: String
) -> AnyPublisher<CurrentWeatherForecastResponse, WeatherError> {
return forecast(with: makeCurrentDayForecastComponents(withCity: city))
}
private func forecast<T>(
with components: URLComponents
) -> AnyPublisher<T, WeatherError> where T: Decodable {
guard let url = components.url else {
let error = WeatherError.network(description: "Couldn't create URL")
return Fail(error: error).eraseToAnyPublisher()
}
return session.dataTaskPublisher(for: URLRequest(url: url))
.mapError { error in
.network(description: error.localizedDescription)
}
.flatMap(maxPublishers: .max(1)) { pair in
decode(pair.data)
}
.eraseToAnyPublisher()
}
}
// MARK: - OpenWeatherMap API
private extension WeatherFetcher {
struct OpenWeatherAPI {
static let scheme = "https"
static let host = "api.openweathermap.org"
static let path = "/data/2.5"
static let key = "<your key>"
}
func makeWeeklyForecastComponents(
withCity city: String
) -> URLComponents {
var components = URLComponents()
components.scheme = OpenWeatherAPI.scheme
components.host = OpenWeatherAPI.host
components.path = OpenWeatherAPI.path + "/forecast"
components.queryItems = [
URLQueryItem(name: "q", value: city),
URLQueryItem(name: "mode", value: "json"),
URLQueryItem(name: "units", value: "metric"),
URLQueryItem(name: "APPID", value: OpenWeatherAPI.key)
]
return components
}
func makeCurrentDayForecastComponents(
withCity city: String
) -> URLComponents {
var components = URLComponents()
components.scheme = OpenWeatherAPI.scheme
components.host = OpenWeatherAPI.host
components.path = OpenWeatherAPI.path + "/weather"
components.queryItems = [
URLQueryItem(name: "q", value: city),
URLQueryItem(name: "mode", value: "json"),
URLQueryItem(name: "units", value: "metric"),
URLQueryItem(name: "APPID", value: OpenWeatherAPI.key)
]
return components
}
}
18. WeatherError.swift
import Foundation
enum WeatherError: Error {
case parsing(description: String)
case network(description: String)
}
后记
本篇主要讲述了
Combine
与MVVM
,感兴趣的给个赞或者关注~~~