Difficulty: Beginner | Easy | Normal | Challenging
This article has been developed using Xcode 12.1, and Swift 5.3
If you want to develop any sort of SwiftUI
application you
- You will be expected to make a Single View SwiftUI Application in Swift.
SwiftUI: A simple way to build user interfaces Across Apple platforms
You will need to create nicely featured Apps. They are going to (probably) make network calls. This means that you need to develop an architecture that is going to support this in your development.
Let's go MVVM
!
The Main view (which I've creatively called ContentView
) is instantiated with a view model which is creatively called ContentViewModel
.
Therefore in the SceneDelegate
contentView is created with let contentView = ContentView(viewModel: ContentViewModel())
.
My ContentView
isn't going to do anything in this case, apart from creating a reference to the view model
struct ContentView: View {
@ObservedObject var viewModel: ContentViewModel
init(viewModel: ContentViewModel) {
self.viewModel = viewModel
}
var body: some View {
NavigationView {
List {
ForEach(viewModel.users, id: \.self) {
user in
Text("\(user.title)")
}
}
.navigationBarTitle("User")
.listStyle(GroupedListStyle())
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView(viewModel: ContentViewModel())
}
}
We are going to have the following view model
class ContentViewModel: ObservableObject {
@Published var users: [User] = []
var res: AnyCancellable?
private var networkManager: AnyNetworkManager<URLSession>?
init() {
self.networkManager = AnyNetworkManager(manager: NetworkManager(session: URLSession.shared))
res = networkManager?.fetch(url: URL(string: "https://jsonplaceholder.typicode.com/posts/1")!, method: .get)
.sink(receiveCompletion: {comp in
print (comp)},
receiveValue: {
val in
let decode = JSONDecoder()
let decoded = try? decode.decode(User.self, from: val)
self.users = [decoded!]
})
}
}
Yes! This is all very basic and not production-ready. But that isn't the point of this article rally though (is it?)
Oh yes, we are decoding a basic user:
public struct User: Codable, Hashable {
let userId: Int
let id: Int
let title: String
let body: String
}
This is using a rather nifty network manager, which uses Type Erasure to wrap the Network Manager
which unfortunately has a associated type requirement which (if we want to store the Network Manager) means the owning class would need to be generic - and we are risking having generic classes everywhere within our code.
To avoid that we structure our Network Manager
covers the following
AnyNetworkManager
public class AnyNetworkManager<U: URLSessionProtocol>: NetworkManagerProtocol {
public let session: U
let fetchClosure: (URL, HTTPMethod, [String : String], String?, [String : Any]?) -> AnyPublisher<Data, NetworkError>
public init<T: NetworkManagerProtocol>(manager: T) {
fetchClosure = manager.fetch
session = manager.session as! U
}
public func fetch(url: URL, method: HTTPMethod, headers: [String : String] = [:], token: String? = nil, data: [String: Any]? = nil) -> AnyPublisher<Data, NetworkError> {
fetchClosure(url, method, headers, token, data)
}
}
NetworkManager
(Also containing a couple of enum
)
public enum NetworkError: Error, Equatable {
case bodyInGet
case invalidURL
case noInternet
case invalidResponse(Data?, URLResponse?)
case accessForbidden
case unknown
case httpError(Int)
}
public enum HTTPMethod: String {
case get = "GET"
case post = "POST"
case put = "PUT"
case delete = "DELETE"
case patch = "PATCH"
}
public class NetworkManager<T: URLSessionProtocol> {
public let session: T
public required init(session: T) {
self.session = session
}
public func fetch(url: URL, method: HTTPMethod, headers: [String : String] = [:], token: String? = nil, data: [String: Any]? = nil) -> AnyPublisher<Data, NetworkError> {
var request = URLRequest(url: url, cachePolicy: .useProtocolCachePolicy, timeoutInterval: 30.0)
request.httpMethod = method.rawValue
request.allHTTPHeaderFields = headers
if let bearerToken = token {
request.setValue("Bearer \(bearerToken)", forHTTPHeaderField: "Authorization")
}
if let data = data {
var serializedData: Data?
do {
serializedData = try JSONSerialization.data(withJSONObject: data, options: .prettyPrinted)
} catch {
return Fail(error: NetworkError.bodyInGet)
.eraseToAnyPublisher()
}
request.httpBody = serializedData
}
return URLSession.shared
.dataTaskPublisher(for: request)
.receive(on: DispatchQueue.main)
.mapError { _ in .unknown }
.flatMap { data, response -> AnyPublisher<Data, NetworkError> in
if let response = response as? HTTPURLResponse {
/// successful responses
if (200..<300).contains(response.statusCode) {
return Just(data)
.mapError {_ in
// no matter the error return our NetworkError
.unknown}
.eraseToAnyPublisher()
} else {
return Fail(error: NetworkError.httpError(response.statusCode))
.eraseToAnyPublisher()
}
}
return Fail(error: NetworkError.httpError( (response as? HTTPURLResponse)?.statusCode ?? 0 ))
.eraseToAnyPublisher()
}
.eraseToAnyPublisher()
}
}
NetworkManagerProtocol
public protocol NetworkManagerProtocol {
associatedtype aType
var session: aType { get }
func fetch(url: URL, method: HTTPMethod, headers: [String : String], token: String?, data: [String: Any]?) -> AnyPublisher<Data, NetworkError>
}
extension NetworkManagerProtocol {
public func fetch(url: URL, method: HTTPMethod, headers: [String : String] = [:], token: String?, data: [String: Any]?) -> AnyPublisher<Data, NetworkError> {
return fetch(url: url, method: method, headers: headers, token: token, data: data)
}
}
URLSessionDataTaskProtocol
public protocol URLSessionDataTaskProtocol {
func resume()
}
URLSessionProtocol
public protocol URLSessionProtocol {
associatedtype dataTaskProtocolType: URLSessionDataTaskProtocol
func dataTask(with url: URL, completionHandler: @escaping (Data?, URLResponse?, Error?) -> Void) -> dataTaskProtocolType
func dataTask(with request: URLRequest, completionHandler: @escaping (Data?, URLResponse?, Error?) -> Void) -> dataTaskProtocolType
func dataTaskPublisher(for: URLRequest) -> URLSession.DataTaskPublisher
func dataTaskPublisher(for: URL) -> URLSession.DataTaskPublisher
}
With the following extensions
extension URLSession: URLSessionProtocol {}
extension URLSessionDataTask: URLSessionDataTaskProtocol {}
extension NetworkManager: NetworkManagerProtocol {}
This is an interesting experiment that has led us to having a working network call from a view model.
Good stuff indeed!
Well done to all involved etc.
If you've any questions, comments or suggestions please hit me up on Twitter