美文网首页
架构之路 (六) —— VIPER架构模式(二)

架构之路 (六) —— VIPER架构模式(二)

作者: 刀客传奇 | 来源:发表于2020-04-27 16:48 被阅读0次

    版本记录

    版本号 时间
    V1.0 2020.04.27 星期一

    前言

    前面写了那么多篇主要着眼于局部问题的解决,包括特定功能的实现、通用工具类的封装、视频和语音多媒体的底层和实现以及动画酷炫的实现方式等等。接下来这几篇我们就一起看一下关于iOS系统架构以及独立做一个APP的架构设计的相关问题。感兴趣的可以看上面几篇。
    1. 架构之路 (一) —— iOS原生系统架构(一)
    2. 架构之路 (二) —— APP架构分析(一)
    3. 架构之路 (三) —— APP架构之网络层分析(一)
    4. 架构之路 (四) —— APP架构之工程实践中网络层的搭建(二)
    5. 架构之路 (五) —— VIPER架构模式(一)

    源码

    1. Swift

    首先看下工程组织结构

    下面就是源码了

    1. SceneDelegate.swift
    
    import SwiftUI
    
    class SceneDelegate: UIResponder, UIWindowSceneDelegate {
      var window: UIWindow?
    
      let model = DataModel()
      
      func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
        model.load()
        let contentView = ContentView()
          .environmentObject(model)   
        
        // Use a UIHostingController as window root view controller
        if let windowScene = scene as? UIWindowScene {
          let window = UIWindow(windowScene: windowScene)
          window.rootViewController = UIHostingController(rootView: contentView)
          self.window = window
          window.makeKeyAndVisible()
        }
      }
    }
    
    2. ContentView.swift
    
    import SwiftUI
    
    struct ContentView: View {
      @EnvironmentObject var model: DataModel
    
      var body: some View {
        NavigationView {
          TripListView(presenter:
          TripListPresenter(interactor:
            TripListInteractor(model: model)))
        }
      }
    }
    
    #if DEBUG
    struct ContentView_Previews: PreviewProvider {
      static var previews: some View {
        let model = DataModel.sample
        return ContentView()
          .environmentObject(model)
      }
    }
    #endif
    
    3. TripListInteractor.swift
    
    import Foundation
    
    class TripListInteractor {
      let model: DataModel
    
      init (model: DataModel) {
        self.model = model
      }
    
      func addNewTrip() {
        model.pushNewTrip()
      }
      
      func deleteTrip(_ index: IndexSet) {
        model.trips.remove(atOffsets: index)
      }
    }
    
    4. TripListPresenter.swift
    
    import SwiftUI
    import Combine
    
    class TripListPresenter: ObservableObject {
      private let interactor: TripListInteractor
      private let router = TripListRouter()
      
      private var cancellables = Set<AnyCancellable>()
      
      @Published var trips: [Trip] = []
      
      init(interactor: TripListInteractor) {
        self.interactor = interactor
        
        interactor.model.$trips
          .assign(to: \.trips, on: self)
          .store(in: &cancellables)
      }
      
      func makeAddNewButton() -> some View {
        Button(action: addNewTrip) {
          Image(systemName: "plus")
        }
      }
      
      func addNewTrip() {
        interactor.addNewTrip()
      }
      
      func deleteTrip(_ index: IndexSet) {
        interactor.deleteTrip(index)
      }
      
      func linkBuilder<Content: View>(for trip: Trip, @ViewBuilder content: () -> Content
      ) -> some View {
        NavigationLink(destination: router.makeDetailView(for: trip, model: interactor.model)) {
          content()
        }
      }  
    }
    
    5. TripListView.swift
    
    import SwiftUI
    
    struct TripListView: View {
      @ObservedObject var presenter: TripListPresenter
      
      var body: some View {
        List {
          ForEach (presenter.trips, id: \.id) { item in
            self.presenter.linkBuilder(for: item) {
              TripListCell(trip: item)
                .frame(height: 240)
            }
          }
          .onDelete(perform: presenter.deleteTrip)
        }
        .navigationBarTitle("Roadtrips", displayMode: .inline)
        .navigationBarItems(trailing: presenter.makeAddNewButton())
      }
    }
    
    struct TripListView_Previews: PreviewProvider {
      static var previews: some View {
        let model = DataModel.sample
        let interactor = TripListInteractor(model: model)
        let presenter = TripListPresenter(interactor: interactor)
        return NavigationView {
          TripListView(presenter: presenter)
        }
      }
    }
    
    6. TripListRouter.swift
    
    import SwiftUI
    
    class TripListRouter {
      func makeDetailView(for trip: Trip, model: DataModel) -> some View {
        let presenter = TripDetailPresenter(interactor:
          TripDetailInteractor(
            trip: trip,
            model: model,
            mapInfoProvider: RealMapDataProvider()))
        return TripDetailView(presenter: presenter)
      }
    }
    
    7. TripDetailInteractor.swift
    
    import Combine
    import MapKit
    
    class TripDetailInteractor {
      private let trip: Trip
      private let model: DataModel
      let mapInfoProvider: MapDataProvider
    
      private var cancellables = Set<AnyCancellable>()
    
      var tripName: String { trip.name }
      var tripNamePublisher: Published<String>.Publisher { trip.$name }
      @Published var totalDistance: Measurement<UnitLength> = Measurement(value: 0, unit: .meters)
      @Published var waypoints: [Waypoint] = []
      @Published var directions: [MKRoute] = []
    
    
      init (trip: Trip, model: DataModel, mapInfoProvider: MapDataProvider) {
        self.trip = trip
        self.mapInfoProvider = mapInfoProvider
        self.model = model
    
        trip.$waypoints
          .assign(to: \.waypoints, on: self)
          .store(in: &cancellables)
    
        trip.$waypoints
          .flatMap { mapInfoProvider.totalDistance(for: $0) }
          .map { Measurement(value: $0, unit: UnitLength.meters) }
          .assign(to: \.totalDistance, on: self)
          .store(in: &cancellables)
    
        trip.$waypoints
          .setFailureType(to: Error.self)
          .flatMap { mapInfoProvider.directions(for: $0) }
          .catch { _ in Empty<[MKRoute], Never>()}
          .assign(to: \.directions, on: self)
          .store(in: &cancellables)
      }
    
      func setTripName(_ name: String) {
        trip.name = name
      }
    
      func save() {
        model.save()
      }
    
      // MARK: - Waypoints
    
      func addWaypoint() {
         trip.addWaypoint()
      }
    
      func moveWaypoint(fromOffsets: IndexSet, toOffset: Int) {
       trip.waypoints.move(fromOffsets: fromOffsets, toOffset: toOffset)
      }
    
      func deleteWaypoint(atOffsets: IndexSet) {
        trip.waypoints.remove(atOffsets: atOffsets)
      }
    
      func updateWaypoints() {
        trip.waypoints = trip.waypoints
      }
    }
    
    8. TripDetailPresenter.swift
    
    import SwiftUI
    import Combine
    
    class TripDetailPresenter: ObservableObject {
      private let interactor: TripDetailInteractor
      private let router: TripDetailRouter
    
      private var cancellables = Set<AnyCancellable>()
    
      @Published var tripName: String = "No name"
      let setTripName: Binding<String>
      @Published var distanceLabel: String = "Calculating..."
      @Published var waypoints: [Waypoint] = []
    
      init(interactor: TripDetailInteractor) {
        self.interactor = interactor
        self.router = TripDetailRouter(mapProvider: interactor.mapInfoProvider)
    
        // 1
        setTripName = Binding<String>(
          get: { interactor.tripName },
          set: { interactor.setTripName($0) }
        )
    
        // 2
        interactor.tripNamePublisher
          .assign(to: \.tripName, on: self)
          .store(in: &cancellables)
    
        interactor.$totalDistance
          .map { "Total Distance: " + MeasurementFormatter().string(from: $0) }
          .replaceNil(with: "Calculating...")
          .assign(to: \.distanceLabel, on: self)
          .store(in: &cancellables)
    
        interactor.$waypoints
          .assign(to: \.waypoints, on: self)
          .store(in: &cancellables)
      }
    
      func save() {
        interactor.save()
      }
    
      func makeMapView() -> some View {
        TripMapView(presenter: TripMapViewPresenter(interactor: interactor))
      }
    
      // MARK: - Waypoints
    
      func addWaypoint() {
        interactor.addWaypoint()
      }
    
      func didMoveWaypoint(fromOffsets: IndexSet, toOffset: Int) {
        interactor.moveWaypoint(fromOffsets: fromOffsets, toOffset: toOffset)
      }
    
      func didDeleteWaypoint(_ atOffsets: IndexSet) {
        interactor.deleteWaypoint(atOffsets: atOffsets)
      }
    
      func cell(for waypoint: Waypoint) -> some View {
        let destination = router.makeWaypointView(for: waypoint)
          .onDisappear(perform: interactor.updateWaypoints)
        return NavigationLink(destination: destination) {
          Text(waypoint.name)
        }
      }
    }
    
    9. TripDetailView.swift
    
    import SwiftUI
    
    struct TripDetailView: View {
      @ObservedObject var presenter: TripDetailPresenter
    
      var body: some View {
        VStack {
          TextField("Trip Name", text: presenter.setTripName)
            .textFieldStyle(RoundedBorderTextFieldStyle())
            .padding([.horizontal])
          presenter.makeMapView()
          Text(presenter.distanceLabel)
          HStack {
            Spacer()
            EditButton()
            Button(action: presenter.addWaypoint) {
              Text("Add")
            }
          }.padding([.horizontal])
          List {
            ForEach(presenter.waypoints, content: presenter.cell)
              .onMove(perform: presenter.didMoveWaypoint(fromOffsets:toOffset:))
              .onDelete(perform: presenter.didDeleteWaypoint(_:))
          }
        }
        .navigationBarTitle(Text(presenter.tripName), displayMode: .inline)
        .navigationBarItems(trailing: Button("Save", action: presenter.save))
      }
    }
    
    struct TripDetailView_Previews: PreviewProvider {
      static var previews: some View {
        let model = DataModel.sample
        let trip = model.trips[1]
        let mapProvider = RealMapDataProvider()
        let presenter = TripDetailPresenter(interactor:
          TripDetailInteractor(
            trip: trip,
            model: model,
            mapInfoProvider: mapProvider))
        return NavigationView {
          TripDetailView(presenter: presenter)
        }
      }
    }
    
    10. TripMapViewPresenter.swift
    
    import MapKit
    import Combine
    
    class TripMapViewPresenter: ObservableObject {
      @Published var pins: [MKAnnotation] = []
      @Published var routes: [MKRoute] = []
    
      let interactor: TripDetailInteractor
      private var cancellables = Set<AnyCancellable>()
    
      init(interactor: TripDetailInteractor) {
        self.interactor = interactor
    
        interactor.$waypoints
          .map {
            $0.map {
              let annotation = MKPointAnnotation()
              annotation.coordinate = $0.location
              return annotation
            }
        }
        .assign(to: \.pins, on: self)
        .store(in: &cancellables)
    
        interactor.$directions
          .assign(to: \.routes, on: self)
          .store(in: &cancellables)
      }
    }
    
    11. TripMapView.swift
    
    import SwiftUI
    
    struct TripMapView: View {
      @ObservedObject var presenter: TripMapViewPresenter
    
      var body: some View {
        MapView(pins: presenter.pins, routes: presenter.routes)
      }
    }
    
    #if DEBUG
    struct TripMapView_Previews: PreviewProvider {
      static var previews: some View {
        let model = DataModel.sample
        let trip = model.trips[0]
        let interactor = TripDetailInteractor(
          trip: trip,
          model: model,
          mapInfoProvider: RealMapDataProvider())
        let presenter = TripMapViewPresenter(interactor: interactor)
        return VStack {
          TripMapView(presenter: presenter)
        }
      }
    }
    #endif
    
    12. TripDetailRouter.swift
    
    import SwiftUI
    
    class TripDetailRouter {
      private let mapProvider: MapDataProvider
    
      init(mapProvider: MapDataProvider) {
        self.mapProvider = mapProvider
      }
    
      func makeWaypointView(for waypoint: Waypoint) -> some View {
        let presenter = WaypointViewPresenter(
          waypoint: waypoint,
          interactor: WaypointViewInteractor(
            waypoint: waypoint,
            mapInfoProvider: mapProvider))
        return WaypointView(presenter: presenter)
      }
    }
    
    13. MapView.swift
    
    import SwiftUI
    import MapKit
    
    struct MapView: UIViewRepresentable {
      var pins: [MKAnnotation] = []
      var routes: [MKRoute]?
      var center: CLLocationCoordinate2D?
    
      func makeUIView(context: Context) -> MKMapView {
        let mapView = MKMapView()
        mapView.delegate = context.coordinator
        return mapView
      }
    
      func updateUIView(_ view: MKMapView, context: Context) {
        view.removeAnnotations(view.annotations)
        view.removeOverlays(view.overlays)
        if let center = center {
          view.setRegion(MKCoordinateRegion(center: center, latitudinalMeters: 2000, longitudinalMeters: 2000), animated: true)
          view.addAnnotation( {
            let annotation = MKPointAnnotation()
            annotation.coordinate = center
            return annotation
            }())
        }
        if pins.count > 0 {
          view.addAnnotations(pins)
          view.showAnnotations(pins, animated: false)
        }
        if let routes = routes {
          routes.forEach { route in
            view.addOverlay(route.polyline, level: .aboveRoads)
          }
        }
      }
    
      func makeCoordinator() -> Coordinator {
        Coordinator(self)
      }
    
      class Coordinator: NSObject, MKMapViewDelegate {
        var parent: MapView
    
        init(_ parent: MapView) {
          self.parent = parent
        }
    
        func mapView(_ mapView: MKMapView, rendererFor overlay: MKOverlay) -> MKOverlayRenderer {
          guard let polyline = overlay as? MKPolyline else {
            return MKOverlayRenderer(overlay: overlay)
          }
    
          let lineRenderer = MKPolylineRenderer(polyline: polyline)
          lineRenderer.strokeColor = .blue
          lineRenderer.lineWidth = 3
    
          return lineRenderer
        }
      }
    }
    
    fileprivate class CoordinateWrapper: NSObject, MKAnnotation {
      var coordinate: CLLocationCoordinate2D
    
      init(_ coordinate: CLLocationCoordinate2D) {
        self.coordinate = coordinate
      }
    }
    
    #if DEBUG
    struct MapView_Previews: PreviewProvider {
      static var previews: some View {
        let pins = DataModel.sample.trips[0].waypoints.map { waypoint -> MKPointAnnotation in
          let annotation = MKPointAnnotation()
          annotation.coordinate = waypoint.location
          return annotation
        }
        return Group {
          MapView(pins: pins, routes: nil, center: nil)
            .previewDisplayName("Pins")
          MapView(pins: [], routes: nil, center: CLLocationCoordinate2D.timesSquare)
            .previewDisplayName("Centered")
        }
      }
    }
    #endif
    
    14. SplitImage.swift
    
    import SwiftUI
    
    struct SplitImage: View {
      var images: [UIImage]
    
      func defaultImageView() -> some View {
        Image("no_waypoints")
          .resizable()
          .aspectRatio(contentMode: .fill)
      }
    
      func image(for uiImage: UIImage) -> some View {
        return Image(uiImage: uiImage)
          .resizable()
          .aspectRatio(contentMode: .fill)
      }
    
      func oneImageView() -> some View {
        image(for: images[0])
      }
    
      func twoImagesView() -> some View {
        GeometryReader { geometry in
          ZStack(alignment: .leading) {
            self.image(for: self.images[0])
              .frame(width: geometry.size.width)
              .clipShape(TopTriangle(offset: 4))
            self.image(for: self.images[1])
              .frame(width: geometry.size.width)
              .clipShape(BottomTriangle(offset: 4))
          }
        }
      }
    
      var body: some View {
        if images.count == 0 {
          return AnyView(defaultImageView())
        }
        if images.count == 1 {
          return AnyView(oneImageView())
        }
        return AnyView(twoImagesView())
      }
    }
    
    struct TopTriangle: Shape {
      var offset: CGFloat = 2
      func path(in rect: CGRect) -> Path {
        var path = Path()
    
        path.move(to: CGPoint(x: rect.minX, y: rect.minY))
        path.addLine(to: CGPoint(x: rect.maxX - offset, y: rect.minY))
        path.addLine(to: CGPoint(x: rect.minX, y: rect.maxY - offset))
        path.closeSubpath()
        return path
      }
    }
    
    struct BottomTriangle: Shape {
      var offset: CGFloat = 2
    
      func path(in rect: CGRect) -> Path {
        var path = Path()
    
        path.move(to: CGPoint(x: rect.minX + offset, y: rect.maxY))
        path.addLine(to: CGPoint(x: rect.maxX, y: rect.minY + offset))
        path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY))
        path.closeSubpath()
        return path
      }
    }
    
    #if DEBUG
    struct SplitImage_Previews: PreviewProvider {
      static var previews: some View {
        Group {
          SplitImage(images: [])
            .frame(height: 200)
          SplitImage(images: [UIImage(named: "waypoint.0")!])
            .frame(height: 100)
          SplitImage(images: [UIImage(named: "waypoint.1")!])
            .frame(height: 100)
          SplitImage(images: [UIImage(named: "waypoint.0")!, UIImage(named: "waypoint.1")!])
            .frame(height: 100)
        }
      }
    }
    #endif
    
    15. TripListCell.swift
    
    import SwiftUI
    import Combine
    
    struct TripListCell: View {
      let imageProvider: ImageDataProvider = PixabayImageDataProvider() // this could be injected in the future
      @ObservedObject var trip: Trip
    
      @State private var images: [UIImage] = []
      @State private var cancellable: AnyCancellable?
    
      var body: some View {
        GeometryReader { geometry in
          ZStack(alignment: .bottomLeading) {
            SplitImage(images: self.images)
              .frame(width: geometry.size.width, height: geometry.size.height)
            BlurView()
              .frame(width: geometry.size.width, height: 42)
            Text(self.trip.name)
              .font(.system(size: 32))
              .fontWeight(.bold)
              .foregroundColor(.white)
              .padding(EdgeInsets(top: 0, leading: 8, bottom: 4, trailing: 8))
          }
          .cornerRadius(12)
        }.onAppear() {
          self.cancellable = self.imageProvider.getEndImages(for: self.trip).assign(to: \.images, on: self)
        }
      }
    }
    
    #if DEBUG
    struct TripListCell_Previews: PreviewProvider {
      static var previews: some View {
        let model = DataModel.sample
        let trip = model.trips[0]
        return TripListCell(trip: trip)
          .frame(height: 160)
          .environmentObject(model)
      }
    }
    #endif
    
    struct BlurView: UIViewRepresentable {
      func makeUIView(context: UIViewRepresentableContext<BlurView>) -> UIView {
        let view = UIView()
        view.backgroundColor = .clear
        let blurEffect = UIBlurEffect(style: .systemUltraThinMaterial)
        let blurView = UIVisualEffectView(effect: blurEffect)
        blurView.translatesAutoresizingMaskIntoConstraints = false
        view.insertSubview(blurView, at: 0)
        NSLayoutConstraint.activate([
          blurView.heightAnchor.constraint(equalTo: view.heightAnchor),
          blurView.widthAnchor.constraint(equalTo: view.widthAnchor),
        ])
        return view
      }
    
      func updateUIView(_ uiView: UIView, context: UIViewRepresentableContext<BlurView>) {
      }
    }
    
    16. Trip.swift
    
    import Foundation
    import Combine
    
    final class Trip {
      @Published var name: String = ""
      @Published var waypoints: [Waypoint] = []
      let id: UUID
    
      init() {
        id = UUID()
      }
    
      required init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        name = try container.decode(String.self, forKey: .name)
        waypoints = try container.decode([Waypoint].self, forKey: .waypoints)
        id = try container.decode(UUID.self, forKey: .id)
      }
    
      func addWaypoint() {
        let waypoint = waypoints.last?.copy() ?? Waypoint()
        waypoint.name = "New Stop"
        waypoints.append(waypoint)
      }
    }
    
    extension Trip: Codable {
      enum CodingKeys: CodingKey {
        case name
        case waypoints
        case id
      }
    
      func encode(to encoder: Encoder) throws {
        var container = encoder.container(keyedBy: CodingKeys.self)
        try container.encode(name, forKey: .name)
        try container.encode(waypoints, forKey: .waypoints)
        try container.encode(id, forKey: .id)
      }
    }
    
    extension Trip: Equatable {
      static func == (lhs: Trip, rhs: Trip) -> Bool {
        lhs.id == rhs.id
      }
    }
    
    extension Trip: Identifiable {}
    
    extension Trip: ObservableObject {}
    
    17. Waypoint.swift
    
    import Combine
    import CoreLocation
    import MapKit
    
    final class Waypoint {
      @Published var name: String
      @Published var location: CLLocationCoordinate2D
      var id: UUID
    
      init() {
        id = UUID()
        name = "Times Square"
        location = CLLocationCoordinate2D.timesSquare
      }
    
      required init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        name = try container.decode(String.self, forKey: .name)
        location = try container.decode(CLLocationCoordinate2D.self, forKey: .location)
        id = try container.decode(UUID.self, forKey: .id)
      }
    
      func copy() -> Waypoint {
        let new = Waypoint()
        new.name = name
        new.location = location
        return new
      }
    }
    
    extension Waypoint: Equatable {
      static func == (lhs: Waypoint, rhs: Waypoint) -> Bool {
        return lhs.id == rhs.id
      }
    }
    
    extension Waypoint: CustomStringConvertible {
      var description: String { name }
    }
    
    extension Waypoint: Codable {
      enum CodingKeys: CodingKey {
        case name
        case location
        case id
      }
    
      func encode(to encoder: Encoder) throws {
        var container = encoder.container(keyedBy: CodingKeys.self)
        try container.encode(name, forKey: .name)
        try container.encode(location, forKey: .location)
        try container.encode(id, forKey: .id)
      }
    }
    
    extension Waypoint: Identifiable {}
    
    extension Waypoint {
      var mapItem: MKMapItem {
        return MKMapItem(placemark: MKPlacemark(coordinate: location))
      }
    }
    
    extension CLLocationCoordinate2D: Codable {
      public init(from decoder: Decoder) throws {
        let representation = try decoder.singleValueContainer().decode([String: CLLocationDegrees].self)
        self.init(latitude: representation["latitude"] ?? 0, longitude:  representation["longitude"] ?? 0)
      }
    
      public func encode(to encoder: Encoder) throws {
        let representation = ["latitude": self.latitude, "longitude": self.longitude]
        try representation.encode(to: encoder)
      }
    }
    
    18. DataModel.swift
    
    import Combine
    
    final class DataModel {
      private let persistence = Persistence()
    
      @Published var trips: [Trip] = []
    
      private var cancellables = Set<AnyCancellable>()
    
      func load() {
        persistence.load()
          .assign(to: \.trips, on: self)
          .store(in: &cancellables)
      }
    
      func save() {
        persistence.save(trips: trips)
      }
    
      func loadDefault(synchronous: Bool = false) {
        persistence.loadDefault(synchronous: synchronous)
          .assign(to: \.trips, on: self)
          .store(in: &cancellables)
      }
    
      func pushNewTrip() {
        let new = Trip()
        new.name = "New Trip"
        trips.insert(new, at: 0)
      }
    
      func removeTrip(trip: Trip) {
        trips.removeAll { $0.id == trip.id }
      }
    }
    
    extension DataModel: ObservableObject {}
    
    /// Extension for SwiftUI previews
    #if DEBUG
    extension DataModel {
      static var sample: DataModel {
        let model = DataModel()
        model.loadDefault(synchronous: true)
        return model
      }
    }
    #endif
    
    19. Persistence.swift
    
    import Foundation
    import Combine
    
    fileprivate struct Envelope: Codable {
      let trips: [Trip]
    }
    
    /// This class can be refactored to save/load over a network instead of a local file
    class Persistence {
      var localFile: URL {
        let fileURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0].appendingPathComponent("trips.json")
        print("In case you need to delete the database: \(fileURL)")
        return fileURL
      }
    
      var defaultFile: URL {
        return Bundle.main.url(forResource: "default", withExtension: "json")!
      }
      
      private func clear() {
        try? FileManager.default.removeItem(at: localFile)
      }
    
      func load() -> AnyPublisher<[Trip], Never>  {
        if FileManager.default.fileExists(atPath: localFile.standardizedFileURL.path) {
          return Future<[Trip], Never> { promise in
            self.load(self.localFile) { trips in
              DispatchQueue.main.async {
                promise(.success(trips))
              }
            }
          }.eraseToAnyPublisher()
        } else {
          return loadDefault()
        }
      }
    
      func save(trips: [Trip]) {
        let envelope = Envelope(trips: trips)
        let encoder = JSONEncoder()
        encoder.outputFormatting = .prettyPrinted
        let data = try! encoder.encode(envelope)
        try! data.write(to: localFile)
      }
    
      private func loadSynchronously(_ file: URL) -> [Trip] {
        do {
          let data = try Data(contentsOf: file)
          let envelope = try JSONDecoder().decode(Envelope.self, from: data)
          return envelope.trips
        } catch {
          clear()
          return loadSynchronously(defaultFile)
        }
      }
    
      private func load(_ file: URL, completion: @escaping ([Trip]) -> Void) {
        DispatchQueue.global(qos: .background).async {
          let trips = self.loadSynchronously(file)
          completion(trips)
        }
      }
    
      func loadDefault(synchronous: Bool = false) -> AnyPublisher<[Trip], Never> {
        if synchronous {
          return Just<[Trip]>(loadSynchronously(defaultFile)).eraseToAnyPublisher()
        }
        return Future<[Trip], Never> { promise in
          self.load(self.defaultFile) { trips in
            DispatchQueue.main.async {
              promise(.success(trips))
            }
          }
        }.eraseToAnyPublisher()
      }
    }
    
    20. MapDataProvider.swift
    
    import Foundation
    import Combine
    import MapKit
    import CoreLocation
    
    protocol MapDataProvider {
      func getLocation(for address:String) -> AnyPublisher<CLPlacemark, Error>
      func directions(for waypoints:[Waypoint]) -> AnyPublisher<[MKRoute], Error>
      func totalDistance(for trip: [Waypoint]) -> AnyPublisher<Double, Never>
    }
    
    enum CustomErrors: String, Error {
      case unknown
      case noData
    }
    
    class RealMapDataProvider: MapDataProvider {
      let geocoder = CLGeocoder()
    
      func getLocation(for address:String) -> AnyPublisher<CLPlacemark, Error> {
        let subject = PassthroughSubject< CLPlacemark, Error>()
    
        geocoder.geocodeAddressString(address) { placemarks, error in
          if let placemark = placemarks?.first {
            subject.send(placemark)
            subject.send(completion: .finished)
          } else if let error = error {
            subject.send(completion: .failure(error))
          } else {
            subject.send(completion: .failure(CustomErrors.unknown))
          }
        }
    
        return subject
          .eraseToAnyPublisher()
      }
    
      func directions(for waypoints:[Waypoint]) -> AnyPublisher<[MKRoute], Error> {
        guard waypoints.count > 1 else {
          return Empty<[MKRoute], Error>().eraseToAnyPublisher()
        }
    
        var routePublishers: [AnyPublisher<[MKRoute], Error>] = []
    
        (0 ..< waypoints.count - 1).forEach { index in
          let start = waypoints[index]
          let end = waypoints[index + 1]
    
          let request = MKDirections.Request()
          request.transportType = .automobile
          request.source = start.mapItem
          request.destination = end.mapItem
    
          let directions = MKDirections(request: request)
          routePublishers.append(directions.calculate())
        }
    
        let allPublisher = Publishers.Sequence<[AnyPublisher<[MKRoute], Error>], Error>(sequence: routePublishers)
        return allPublisher.flatMap { $0 }
          .collect()
          .map { $0.compactMap { $0.first }} // get just the first route and make a list
          .receive(on: DispatchQueue.main)
          .eraseToAnyPublisher()
        }
    
      func totalDistance(for trip: [Waypoint]) -> AnyPublisher<Double, Never> {
        return directions(for: trip)
          .replaceError(with: [])
          .map { routes in
            routes.map { route in
              route.distance
            }.reduce(0, +)
          }
          .receive(on: DispatchQueue.main)
          .eraseToAnyPublisher()
      }
    }
    
    extension MKDirections {
      func calculate() -> AnyPublisher<[MKRoute], Error> {
        let subject = PassthroughSubject<[MKRoute], Error>()
        calculate { response, error in
          if let routes = response?.routes {
            subject.send(routes)
            subject.send(completion: .finished)
          } else if let error = error {
            subject.send(completion: .failure(error))
          } else {
            subject.send(completion: .finished)
          }
        }
        return subject.eraseToAnyPublisher()
      }
    }
    
    extension CLLocationCoordinate2D {
      static var timesSquare: CLLocationCoordinate2D { CLLocationCoordinate2D(latitude: 40.757, longitude: -73.986)}
    }
    
    21. ImageDataProvider.swift
    
    import UIKit
    import Combine
    
    protocol ImageDataProvider {
      func getEndImages(for trip: Trip) -> AnyPublisher<[UIImage], Never>
    }
    
    private struct PixabayResponse: Codable {
      struct Image: Codable {
        let largeImageURL: String
        let user: String
      }
    
      let hits: [Image]
    }
    
    //Get an API Key here: https://pixabay.com/accounts/register/
    class PixabayImageDataProvider: ImageDataProvider {
      let apiKey = "<#Enter your API key here#>"
    
      private func searchURL(query: String) -> URL {
        var components = URLComponents(string: "https://pixabay.com/api")!
        components.queryItems = [
          URLQueryItem(name: "key", value: apiKey),
          URLQueryItem(name: "q", value: query),
          URLQueryItem(name: "image_type", value: "photo")
        ]
        return components.url!
      }
    
      private func imageForQuery(query: String) -> AnyPublisher<UIImage, Never> {
        URLSession.shared.dataTaskPublisher(for: searchURL(query: query))
        .map { $0.data }
        .decode(type: PixabayResponse.self, decoder: JSONDecoder())
          .tryMap { response -> URL in
            guard
              let urlString = response.hits.first?.largeImageURL,
              let url = URL(string: urlString)
              else {
                throw CustomErrors.noData
            }
              return url
        }.catch { _ in Empty<URL, URLError>() }
          .flatMap { URLSession.shared.dataTaskPublisher(for: $0) }
          .map { $0.data }
          .tryMap { imageData in
            guard let image = UIImage(data: imageData) else { throw CustomErrors.noData }
            return image
        }.catch { _ in Empty<UIImage, Never>()}
        .eraseToAnyPublisher()
      }
    
      func getEndImages(for trip: Trip) -> AnyPublisher<[UIImage], Never> {
        if trip.waypoints.count == 0 {
          return Empty<[UIImage], Never>()
            .eraseToAnyPublisher()
        }
        if trip.waypoints.count == 1 {
          return imageForQuery(query: trip.waypoints[0].name)
            .map { [$0] }
            .receive(on: DispatchQueue.main)
            .eraseToAnyPublisher()
        }
    
        let start = imageForQuery(query: trip.waypoints[0].name)
        let end = imageForQuery(query: trip.waypoints.last!.name)
    
        return Publishers.Merge(start, end)
          .collect()
          .receive(on: DispatchQueue.main)
          .eraseToAnyPublisher()
      }
    }
    
    22. WaypointViewPresenter.swift
    
    import Combine
    import SwiftUI
    import CoreLocation
    
    class WaypointViewPresenter: ObservableObject {
      @Published var query: String = ""
    
      @Published var info: String = "No results"
      @Published var name: String = "unknown"
      @Published var location: CLLocationCoordinate2D
      @Published var isValid: Bool = false
    
      private var cancellables = Set<AnyCancellable>()
    
      private let interactor: WaypointViewInteractor
    
      private func formatInfo(_ placemark: CLPlacemark) -> String {
        var info = placemark.name ?? "unknown"
        if let city = placemark.locality {
          info += ", \(city)"
        }
        if let state = placemark.administrativeArea {
          info += ", \(state)"
        }
        return info
      }
    
      init(waypoint: Waypoint, interactor: WaypointViewInteractor) {
        self.interactor = interactor
        location = waypoint.location
        query = waypoint.name
    
        $query
          .debounce(for: 0.5, scheduler: DispatchQueue.main)
          .sink(receiveValue: handleQuery)
          .store(in: &cancellables)
      }
    
      private func handleQuery(_ query: String) {
        let suggestion = interactor.getLocation(for: query)
    
        suggestion
          .map { self.formatInfo($0) }
          .catch { _ in Empty<String, Never>() }
          .assign(to: \.info, on: self)
          .store(in: &cancellables)
    
        suggestion
          .map { $0.name }
          .replaceNil(with: "unknown")
          .catch { _ in Empty<String, Never>() }
          .assign(to: \.name, on: self)
          .store(in: &cancellables)
    
        suggestion
          .map { $0.location }
          .replaceNil(with: CLLocation(latitude: 0, longitude: 0))
          .catch { _ in Empty<CLLocation, Never>() }
          .map { $0.coordinate }
          .assign(to: \.location, on: self)
          .store(in: &cancellables)
    
        suggestion
          .map { _ in true }
          .catch {_ in Just<Bool>(false) }
          .assign(to: \.isValid, on: self)
          .store(in: &cancellables)
      }
    
      func didTapUseThis() {
        interactor.apply(name: name, location: location)
      }
    }
    
    23. WaypointView.swift
    
    import SwiftUI
    import Combine
    import CoreLocation
    import MapKit
    
    struct WaypointView: View {
      @EnvironmentObject var model: DataModel
      @Environment(\.presentationMode) var mode
    
      @ObservedObject var presenter: WaypointViewPresenter
    
      init(presenter: WaypointViewPresenter) {
        self.presenter = presenter
      }
    
      func applySuggestion() {
        presenter.didTapUseThis()
        mode.wrappedValue.dismiss()
      }
    
      var body: some View {
        return
          VStack{
            VStack {
              TextField("Type an Address", text: $presenter.query)
                .textFieldStyle(RoundedBorderTextFieldStyle())
              HStack {
                Text(presenter.info)
                Spacer()
                Button(action: applySuggestion) {
                  Text("Use this")
                }.disabled(!presenter.isValid)
              }
    
            }.padding([.horizontal])
            MapView(center: presenter.location)
          }.navigationBarTitle(Text(""), displayMode: .inline)
      }
    }
    
    #if DEBUG
    struct WaypointView_Previews: PreviewProvider {
      static var previews: some View {
        let model = DataModel.sample
        let waypoint = model.trips[0].waypoints[0]
        let provider = RealMapDataProvider()
    
        return
          Group {
            NavigationView {
              WaypointView(presenter: WaypointViewPresenter(waypoint: waypoint, interactor: WaypointViewInteractor(waypoint: waypoint, mapInfoProvider: provider)))
                .environmentObject(model)
            }.previewDisplayName("Detail")
            NavigationView {
              WaypointView(presenter: WaypointViewPresenter(waypoint: Waypoint(), interactor: WaypointViewInteractor(waypoint:  Waypoint(), mapInfoProvider: provider)))
                .environmentObject(model)
                .previewDisplayName("New")
            }
        }
      }
    }
    #endif
    
    24. WaypointViewInteractor.swift
    
    import Foundation
    import Combine
    import CoreLocation
    
    class WaypointViewInteractor {
      private let waypoint: Waypoint
      private let mapInfoProvider: MapDataProvider
    
      init(waypoint: Waypoint, mapInfoProvider: MapDataProvider) {
        self.waypoint = waypoint
        self.mapInfoProvider = mapInfoProvider
      }
    
      func getLocation(for address:String) -> AnyPublisher<CLPlacemark, Error> {
        mapInfoProvider.getLocation(for: address)
      }
    
      func apply(name: String, location: CLLocationCoordinate2D) {
        waypoint.name = name
        waypoint.location = location
      }
    }
    

    后记

    本篇主要介绍了VIPER架构模式,感兴趣的给个赞或者关注~~~

    相关文章

      网友评论

          本文标题:架构之路 (六) —— VIPER架构模式(二)

          本文链接:https://www.haomeiwen.com/subject/rsquwhtx.html