Como isolar a lógica de interação cliente-servidor em aplicativos iOS
Publicados: 2022-03-11Atualmente, a maioria dos aplicativos móveis depende muito das interações cliente-servidor. Isso não apenas significa que eles podem descarregar a maioria de suas tarefas pesadas para servidores de back-end, mas também permite que esses aplicativos móveis ofereçam todos os tipos de recursos e funcionalidades que só podem ser disponibilizados pela Internet.
Os servidores back-end geralmente são projetados para oferecer seus serviços por meio de APIs RESTful. Para aplicativos mais simples, muitas vezes nos sentimos tentados a criar um código de espaguete; misturando o código que invoca a API com o restante da lógica do aplicativo. No entanto, à medida que os aplicativos se tornam complexos e lidam com mais e mais APIs, pode se tornar um incômodo interagir com essas APIs de maneira não estruturada e não planejada.
Este artigo discute uma abordagem de arquitetura para criar um módulo de rede de cliente REST limpo para aplicativos iOS que permite manter toda a lógica de interação cliente-servidor isolada do restante do código do aplicativo.
Aplicativos cliente-servidor
Uma interação cliente-servidor típica se parece com isso:
- Um usuário realiza alguma ação (por exemplo, tocar em algum botão ou realizar algum outro gesto na tela).
- O aplicativo prepara e envia uma solicitação HTTP/REST em resposta à ação do usuário.
- O servidor processa a solicitação e responde de acordo com a aplicação.
- O aplicativo recebe a resposta e atualiza a interface do usuário com base nela.
À primeira vista, o processo geral pode parecer simples, mas temos que pensar nos detalhes.
Mesmo supondo que uma API de servidor backend funcione conforme anunciado (o que nem sempre é o caso!), muitas vezes pode ser mal projetada, tornando-a ineficiente ou mesmo difícil de usar. Um aborrecimento comum é que todas as chamadas para a API exigem que o chamador forneça as mesmas informações de forma redundante (por exemplo, como os dados da solicitação são formatados, um token de acesso que o servidor pode usar para identificar o usuário conectado no momento e assim por diante).
Os aplicativos móveis também podem precisar utilizar vários servidores back-end simultaneamente para diferentes propósitos. Um servidor pode, por exemplo, ser dedicado à autenticação do usuário enquanto outro lida apenas com a coleta de análises.
Além disso, um cliente REST típico precisará fazer muito mais do que apenas invocar APIs remotas. A capacidade de cancelar solicitações pendentes ou uma abordagem limpa e gerenciável para lidar com erros são exemplos de funcionalidades que precisam ser incorporadas a qualquer aplicativo móvel robusto.
Uma Visão Geral da Arquitetura
O núcleo do nosso cliente REST será construído nos seguintes componentes:
- Modelos: Classes que descrevem os modelos de dados do nosso aplicativo, refletindo a estrutura dos dados recebidos ou enviados para os servidores backend.
- Analisadores: Responsáveis por decodificar as respostas do servidor e produzir objetos de modelo.
- Erros: Objetos para representar respostas erradas do servidor.
- Cliente: Envia solicitações para servidores back-end e recebe respostas.
- Serviços: Gerenciar operações vinculadas logicamente (por exemplo, autenticação, gerenciamento de dados relacionados ao usuário, análises, etc).
É assim que cada um desses componentes irá interagir entre si:
As setas de 1 a 10 na imagem acima mostram uma sequência ideal de operações entre o aplicativo que invoca um serviço e o serviço que eventualmente retorna os dados solicitados como um objeto de modelo. Cada componente desse fluxo tem um papel específico garantindo a separação de interesses dentro do módulo.
Implementação
Implementaremos nosso cliente REST como parte de nosso aplicativo de rede social imaginário no qual carregaremos uma lista dos amigos do usuário conectado no momento. Assumiremos que nosso servidor remoto usa JSON para respostas.
Vamos começar implementando nossos modelos e analisadores.
De JSON bruto a objetos de modelo
Nosso primeiro modelo, User
, define a estrutura de informações para qualquer usuário da rede social. Para manter as coisas simples, incluiremos apenas campos que são absolutamente necessários para este tutorial (em um aplicativo real, a estrutura normalmente teria muito mais propriedades).
struct User { var id: String var email: String? var name: String? }
Como receberemos todos os dados do usuário do servidor de back-end por meio de sua API, precisamos de uma maneira de analisar a resposta da API em um objeto User
válido. Para fazer isso, adicionaremos um construtor a User
que aceita um objeto JSON analisado ( Dictionary
) como parâmetro. Vamos definir nosso objeto JSON como um tipo de alias:
typealias JSON = [String: Any]
Em seguida, adicionaremos a função construtora à nossa estrutura User
da seguinte maneira:
extension User { init?(json: JSON) { guard let id = json["id"] as? String else { return nil } self.id = id self.email = json["email"] as? String self.name = json["name"] as? String } }
Para preservar o construtor padrão original de User
, adicionamos o construtor por meio de uma extensão no tipo User
.
Em seguida, para criar um objeto User
a partir de uma resposta bruta da API, precisamos executar as duas etapas a seguir:
// Transform raw JSON data to parsed JSON object using JSONSerializer (part of standard library) let userObject = (try? JSONSerialization.jsonObject(with: data, options: [])) as? JSON // Create an instance of `User` structure from parsed JSON object let user = userObject.flatMap(User.init)
Tratamento de erros simplificado
Vamos definir um tipo para representar diferentes erros que podem ocorrer ao tentar interagir com os servidores backend. Podemos dividir todos esses erros em três categorias básicas:
- Sem conectividade com a Internet
- Erros relatados como parte da resposta (por exemplo, erros de validação, direitos de acesso insuficientes, etc.)
- Erros que o servidor não relata como parte da resposta (por exemplo, travamento do servidor, tempo limite de respostas, etc.)
Podemos definir nossos objetos de erro como um tipo de enumeração. E enquanto estamos nisso, é uma boa ideia fazer com que nosso tipo ServiceError
esteja em conformidade com o protocolo Error
. Isso nos permitirá usar e lidar com esses valores de erro usando mecanismos padrão fornecidos pelo Swift (como usar throw
para lançar um erro).
enum ServiceError: Error { case noInternetConnection case custom(String) case other }
Ao contrário noInternetConnection
e other
erros, o erro personalizado tem um valor associado a ele. Isso nos permitirá usar a resposta de erro do servidor como um valor associado ao próprio erro, dando assim mais contexto ao erro.
Agora, vamos adicionar uma propriedade errorDescription
à enumeração ServiceError
para tornar os erros mais descritivos. Adicionaremos mensagens codificadas para o noInternetConnection
e other
erros e usaremos o valor associado como a mensagem para erros custom
.
extension ServiceError: LocalizedError { var errorDescription: String? { switch self { case .noInternetConnection: return "No Internet connection" case .other: return "Something went wrong" case .custom(let message): return message } } }
Há apenas mais uma coisa que precisamos implementar em nossa enumeração ServiceError
. No caso de um erro custom
, precisamos transformar os dados JSON do servidor em um objeto de erro. Para fazer isso, usamos a mesma abordagem que usamos no caso de modelos:
extension ServiceError { init(json: JSON) { if let message = json["message"] as? String { self = .custom(message) } else { self = .other } } }
Fazendo a ponte entre o aplicativo e o servidor de back-end
O componente cliente será um intermediário entre o aplicativo e o servidor backend. É um componente crítico que definirá como o aplicativo e o servidor se comunicarão, mas não saberá nada sobre os modelos de dados e suas estruturas. O cliente será responsável por invocar URLs específicos com parâmetros fornecidos e retornar dados JSON de entrada analisados como objetos JSON.
enum RequestMethod: String { case get = "GET" case post = "POST" case put = "PUT" case delete = "DELETE" } final class WebClient { private var baseUrl: String init(baseUrl: String) { self.baseUrl = baseUrl } func load(path: String, method: RequestMethod, params: JSON, completion: @escaping (Any?, ServiceError?) -> ()) -> URLSessionDataTask? { // TODO: Add implementation } }
Vamos examinar o que está acontecendo no código acima…

Primeiro, declaramos um tipo de enumeração, RequestMethod
, que descreve quatro métodos HTTP comuns. Esses estão entre os métodos usados em APIs REST.
A classe WebClient
contém a propriedade baseURL
que será usada para resolver todas as URLs relativas que receber. Caso nosso aplicativo precise interagir com vários servidores, podemos criar várias instâncias de WebClient
, cada uma com um valor diferente para baseURL
.
O Cliente tem um único método load
, que usa um caminho relativo a baseURL
como parâmetro, método de solicitação, parâmetros de solicitação e encerramento de conclusão. O encerramento de conclusão é invocado com o JSON analisado e ServiceError
como parâmetros. Por enquanto, o método acima carece de uma implementação, à qual chegaremos em breve.
Antes de implementar o método load
, precisamos de uma forma de criar uma URL
a partir de todas as informações disponíveis para o método. Vamos estender a classe URL
para esta finalidade:
extension URL { init(baseUrl: String, path: String, params: JSON, method: RequestMethod) { var components = URLComponents(string: baseUrl)! components.path += path switch method { case .get, .delete: components.queryItems = params.map { URLQueryItem(name: $0.key, value: String(describing: $0.value)) } default: break } self = components.url! } }
Aqui nós simplesmente adicionamos o caminho para a URL base. Para os métodos HTTP GET e DELETE, também adicionamos os parâmetros de consulta à string de URL.
Em seguida, precisamos ser capazes de criar instâncias de URLRequest
a partir de determinados parâmetros. Para fazer isso, faremos algo semelhante ao que fizemos para URL
:
extension URLRequest { init(baseUrl: String, path: String, method: RequestMethod, params: JSON) { let url = URL(baseUrl: baseUrl, path: path, params: params, method: method) self.init(url: url) httpMethod = method.rawValue setValue("application/json", forHTTPHeaderField: "Accept") setValue("application/json", forHTTPHeaderField: "Content-Type") switch method { case .post, .put: httpBody = try! JSONSerialization.data(withJSONObject: params, options: []) default: break } } }
Aqui, primeiro criamos uma URL
usando o construtor da extensão. Em seguida, inicializamos uma instância de URLRequest
com este URL
, definimos alguns cabeçalhos HTTP conforme necessário e, em caso de métodos POST ou PUT HTTP, adicionamos parâmetros ao corpo da solicitação.
Agora que cobrimos todos os pré-requisitos, podemos implementar o método load
:
final class WebClient { private var baseUrl: String init(baseUrl: String) { self.baseUrl = baseUrl } func load(path: String, method: RequestMethod, params: JSON, completion: @escaping (Any?, ServiceError?) -> ()) -> URLSessionDataTask? { // Checking internet connection availability if !Reachability.isConnectedToNetwork() { completion(nil, ServiceError.noInternetConnection) return nil } // Adding common parameters var parameters = params if let token = KeychainWrapper.itemForKey("application_token") { parameters["token"] = token } // Creating the URLRequest object let request = URLRequest(baseUrl: baseUrl, path: path, method: method, params: params) // Sending request to the server. let task = URLSession.shared.dataTask(with: request) { data, response, error in // Parsing incoming data var object: Any? = nil if let data = data { object = try? JSONSerialization.jsonObject(with: data, options: []) } if let httpResponse = response as? HTTPURLResponse, (200..<300) ~= httpResponse.statusCode { completion(object, nil) } else { let error = (object as? JSON).flatMap(ServiceError.init) ?? ServiceError.other completion(nil, error) } } task.resume() return task } }
O método load
acima executa as seguintes etapas:
- Verifique a disponibilidade da conexão com a Internet. Se a conectividade com a Internet não estiver disponível, chamamos o encerramento de conclusão imediatamente com o erro
noInternetConnection
como parâmetro. (Observação: AReachability
no código é uma classe personalizada, que usa uma das abordagens comuns para verificar a conexão com a Internet.) - Adicione parâmetros comuns. . Isso pode incluir parâmetros comuns, como um token de aplicativo ou ID de usuário.
- Crie o objeto
URLRequest
, usando o construtor da extensão. - Envie a solicitação para o servidor. Usamos o objeto
URLSession
para enviar dados ao servidor. - Analisar dados de entrada. Quando o servidor responde, primeiro analisamos a carga útil da resposta em um objeto JSON usando
JSONSerialization
. Em seguida, verificamos o código de status da resposta. Se for um código de sucesso (ou seja, no intervalo entre 200 e 299), chamamos o encerramento de conclusão com o objeto JSON. Caso contrário, transformamos o objeto JSON em um objetoServiceError
e chamamos o encerramento de conclusão com esse objeto de erro.
Definindo serviços para operações vinculadas logicamente
No caso do nosso aplicativo, precisamos de um serviço que trate de tarefas relacionadas aos amigos de um usuário. Para isso, criamos uma classe FriendsService
. Idealmente, uma classe como essa será responsável por operações como obter uma lista de amigos, adicionar um novo amigo, remover um amigo, agrupar alguns amigos em uma categoria, etc. Para simplificar neste tutorial, implementaremos apenas um método :
final class FriendsService { private let client = WebClient(baseUrl: "https://your_server_host/api/v1") @discardableResult func loadFriends(forUser user: User, completion: @escaping ([User]?, ServiceError?) -> ()) -> URLSessionDataTask? { let params: JSON = ["user_id": user.id] return client.load(path: "/friends", method: .get, params: params) { result, error in let dictionaries = result as? [JSON] completion(dictionaries?.flatMap(User.init), error) } } }
A classe FriendsService
contém uma propriedade de client
do tipo WebClient
. É inicializado com a URL base do servidor remoto que se encarrega de gerenciar os amigos. Como mencionado anteriormente, em outras classes de serviço, podemos ter uma instância diferente de WebClient
inicializada com uma URL diferente, se necessário.
No caso de uma aplicação que trabalha com apenas um servidor, a classe WebClient
pode receber um construtor que inicializa com a URL desse servidor:
final class WebClient { // ... init() { self.baseUrl = "https://your_server_base_url" } // ... }
O método loadFriends
, quando invocado, prepara todos os parâmetros necessários e usa a instância de WebClient
de FriendService
para fazer uma solicitação de API. Após receber a resposta do servidor por meio do WebClient
, ele transforma o objeto JSON em modelos User
e chama o encerramento de conclusão com eles como parâmetro.
Um uso típico do FriendService
pode ser parecido com o seguinte:
let friendsTask: URLSessionDataTask! let activityIndicator: UIActivityIndicatorView! var friends: [User] = [] func friendsButtonTapped() { friendsTask?.cancel() //Cancel previous loading task. activityIndicator.startAnimating() //Show loading indicator friendsTask = FriendsService().loadFriends(forUser: currentUser) {[weak self] friends, error in DispatchQueue.main.async { self?.activityIndicator.stopAnimating() //Stop loading indicators if let error = error { print(error.localizedDescription) //Handle service error } else if let friends = friends { self?.friends = friends //Update friends property self?.updateUI() //Update user interface } } } }
No exemplo acima, estamos assumindo que a função friendsButtonTapped
é invocada sempre que o usuário toca em um botão destinado a mostrar uma lista de seus amigos na rede. Também mantemos uma referência à tarefa na propriedade friendsTask
para que possamos cancelar a solicitação a qualquer momento chamando friendsTask?.cancel()
.
Isso nos permite ter um maior controle do ciclo de vida das solicitações pendentes, permitindo-nos encerrá-las quando determinamos que elas se tornaram irrelevantes.
Conclusão
Neste artigo, compartilhei uma arquitetura simples de um módulo de rede para seu aplicativo iOS que é trivial de implementar e pode ser adaptado às intrincadas necessidades de rede da maioria dos aplicativos iOS. No entanto, a principal conclusão disso é que um cliente REST projetado adequadamente e os componentes que o acompanham – que são isolados do restante da lógica do seu aplicativo – podem ajudar a manter o código de interação cliente-servidor do seu aplicativo simples, mesmo que o próprio aplicativo se torne cada vez mais complexo .
Espero que você ache este artigo útil para construir seu próximo aplicativo iOS. Você pode encontrar o código-fonte deste módulo de rede no GitHub. Confira o código, faça um fork, mude, brinque com ele.
Se você encontrar alguma outra arquitetura mais preferível para você e seu projeto, compartilhe os detalhes na seção de comentários abaixo.