Я пишу и перевожу статьи уже 2.5 года, и в какой-то момент я осознала, что мне не хватает приложения под мои нужды: следить за дедлайнами по сдаче статей, хранить информацию о заказчиках, вести смету, держать под рукой визитку с контактами и т. д. Поэтому я решила создать приложение, которое облегчит планирование моих задач.
В ходе реализации мне потребовался бесплатный (или условно бесплатный) онлайн-сервис для хранения данных удаленно, и тогда коллега рассказала мне про Airtable . Однако в интернете мною не были найдены какие-либо статьи по работе с ним (только упоминания), в связи с чем появилась идея написать статью по работе с AirTable для начинающих разработчиков.
AirTable позволяет достаточно просто интегрировать данные в проект. API точно следует семантике REST, использует JSON для кодирования объектов и полагается на стандартные коды HTTP для уведомления о результатах операции. Для работы с сетевым слоем будем использовать Moya , достаточно востребованный и легкий фреймворк.
В интернете полно обучающих статей на самые разные темы, реализованных на простейших архитектурах (MVC и т.п.), но на VIPER -е их не так много. При этом VIPER зачастую спрашивают на собеседованиях даже у джунов. Поэтому приложение напишем с использованием этой архитектуры.
Сначала мы рассмотрим простое приложение на VIPER , а затем пошагово добавим AirTable и Moya. В рамках знакомства я упростила код уже готового проекта, убрав лишние модули и переменные, чтобы это не помешало знакомству с обозначенными выше темами.
VIPER
За идею реализации архитектуры VIPER был взят вариант пользователя Alfian Losari . Данная реализация отлично подойдет для знакомства с VIPER, код понятен, его легко читать и масштабировать. Советую ознакомиться с подробной теорией в видео .
Стандартная схема VIPER выглядит так:
Изображение взято отсюда .
Архитектура VIPER состоит из следующих компонентов:
Entity – отвечает за хранение сущностей (например, у нас это сущность "OrderItem", в которой хранятся заказы).
Interactor – посредник между Entity (сущностями) и Presenter. Бизнес-логика приложения хранится здесь.
Presenter – своеобразный мост между всеми важными частями VIPER (кроме Entity). С одной стороны, он получает на входе события, поступающие из View, и реагирует на них, запрашивая данные у Interactor. С другой стороны, он получает данные, поступающие от Interactor, применяет логику представления к этим данным, и, наконец, сообщает View, что отображать. При этом Presenter ничего не знает про UIKit.
View – представление, которое отвечает за отображение и ничего не знает про данные. Связь только с Presenter.
Router – отвечает за навигационную логику, когда и какие экраны отображаются.
Скачайте стартовый проект по ссылке . Проект состоит из двух модулей: OrderListModule и OrderDetailModule .
OrderListModule представляет собой набор классов для отображения и загрузки списка статей :
OrderDetailModule представляет собой информацию по каждой статье:
Каждый модуль включает в себя View, Interactor, Presenter, Entity, Router, а также необходимые протоколы для сообщения между частями модулей.
Entity отвечает за сущности и является общим для обоих модулей:
В OrderItem.swift содержится одноименный класс OrderItem . У каждой статьи есть название (name), дедлайн сдачи (deadline), заказчик (customer) и примечание/заметка к статье (summary).
import Foundation
class OrderItem {
var summary: String?
var deadline: Date?
var name: String
var customer: String?
init(summary: String?,
deadline: Date?,
name: String,
customer:String?) {
self.summary = summary
self.deadline = deadline
self.name = name
self.customer = customer
}
}
OrderAPI представляет собой имитацию получения данных через сеть.
import Foundation
class OrderAPI {
private init() {}
public static let shared = OrderAPI()
public private(set) var orders: [OrderItem] = [
OrderItem(summary: "How to use AirTable and how to set Moya", deadline: OrderAPI.createTestDate(value: "2023-01-08"), name: "Moya and AirTable in iOS-app", customer: "proglib"),
OrderItem(summary: "Creating Viper app", deadline: OrderAPI.createTestDate(value: "2023-01-08"), name: "VIPER in iOS", customer: "proglib"),
OrderItem(summary: "All about MVVM", deadline: OrderAPI.createTestDate(value: "2023-01-09"), name: "MVVM in iOS", customer: "medium"),
OrderItem(summary: "Some tips", deadline: OrderAPI.createTestDate(value: "2023-01-12"), name: "How to make good apps", customer: "medium"),
]
func addOrder(_ order: OrderItem) {
orders.append(order)
}
static func createTestDate(value: String) -> Date? {
let RFC3339DateFormatter = DateFormatter()
RFC3339DateFormatter.locale = Locale(identifier: "en_US_POSIX")
RFC3339DateFormatter.dateFormat = "yyyy-MM-dd"
RFC3339DateFormatter.timeZone = TimeZone(secondsFromGMT: 0)
//let string = "1996-12-19T16:39:57-08:00"
return RFC3339DateFormatter.date(from: value)
}
}
Подключаем Moya
Создайте файл Podfile и пропишите там:
# Uncomment the next line to define a global platform for your project
platform :ios, '12.0'
use_frameworks!
inhibit_all_warnings!
target 'PlannerTranslator_v4' do
pod 'Moya', '~> 15.0'
pod 'SwiftyJSON', '5.0.1'
end
Если у вас подключены тесты, не забудьте прописать и их:
# Uncomment the next line to define a global platform for your project
platform :ios, '12.0'
use_frameworks!
inhibit_all_warnings!
target 'PlannerTranslator_v4' do
pod 'Moya', '~> 15.0'
pod 'SwiftyJSON', '5.0.1'
target 'PlannerTranslator_v4Tests' do
pod 'Moya', '~> 15.0'
pod 'SwiftyJSON', '5.0.1'
end
end
Затем откройте терминал и выполните:
pod install
Если у вас все хорошо, то в терминале у вас должно появиться:
Далее мы открываем файл с расширением .xcworkspace (не xcodeproj!), иначе проект не запустится:
Все хорошо, мы подключили Moya!
Теперь перейдем к работе с AirTable.
Подключаем AirTable
Регистрируемся на сайте https://airtable.com/
Создаем таблицу (имена переменных проекта и столбцов совпадают) и заполняем ее:
Обратите внимание, что у каждого столбца свой тип данных. Тип данных можно посмотреть через Edit field:
У name, deadline, summary и customer – это Single line text:
У deadline – это Date.
После создания таблицы приступаем к добавлению файлов в проект.
Сначала добавим новую сущность NetworkEntities :
import Foundation
protocol ATProtocol: Codable {
var idAT: String? { get set }
}
struct MoyResponse<T: ATProtocol>: Codable {
let records: [SubMoyResponse<T>]
enum MoyResponseKeys: CodingKey {
case records
}
}
struct SubMoyResponse<T: ATProtocol>: Codable {
let id: String
let createdTime: String
var fields: T
enum SubMoyResponseKeys: CodingKey {
case id,createdTime,fields
}
init(from decoder: Decoder) throws {
let container: KeyedDecodingContainer<SubMoyResponse<T>.CodingKeys> = try decoder.container(keyedBy: SubMoyResponse<T>.CodingKeys.self)
self.id = try container.decode(String.self, forKey: SubMoyResponse<T>.CodingKeys.id)
self.createdTime = try container.decode(String.self, forKey: SubMoyResponse<T>.CodingKeys.createdTime)
self.fields = try container.decode(T.self, forKey: SubMoyResponse<T>.CodingKeys.fields)
self.fields.idAT = self.id
}
}
struct MoyRequest<T: Codable>: Codable {
let records: [SubMoyRequest<T>]
enum MoyRequestKeys: CodingKey {
case records
}
}
struct SubMoyRequest<T: Codable>: Codable {
let id: String?
let fields: T
enum SubMoyRequestKeys: CodingKey {
case id,createdTime,fields
}
func toJSON() -> Dictionary<String, Any> {
do {
let jsonData = try JSONEncoder().encode(self)
let jsonString = String(data: jsonData, encoding: .utf8)!
if let data = jsonString.data(using: .utf8) {
do {
return try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] ?? Dictionary<String, Any>()
} catch {
print(error.localizedDescription)
}
}
return Dictionary<String, Any>()
} catch { print(error) }
return Dictionary<String, Any>()
}
}
После чего для удобства создадим новую группу Moya и создадим 4 файл а:
MoyaRequestType.swift
import Foundation
import Moya
// RequestType включает в себя типы запросов.
public typealias RequestParametersType = (apiStringURL: String, body: [String: Any]?)
//типы запросов
enum RequestType {
case orders
case ordersDetail(String)
case create(OrderItem)
case edit(OrderItem)
}
//TargetType - Протокол, используемый для определения спецификаций, необходимых для файла MoyaProvider.
protocol WDTargetType: TargetType, Hashable {
}
extension RequestType: WDTargetType {
static func == (lhs: RequestType, rhs: RequestType) -> Bool {
lhs.path == rhs.path
}
func hash(into hasher: inout Hasher) {
hasher.combine(path)
hasher.combine(method)
}
//адрес сервера, на котором лежит RESTful API
var baseURL: URL {
URL(string: "https://api.airtable.com/v0/appuggJ5PZ3FDUE2G/")!
}
//роуты запросов
var path: String {
switch self {
case .orders:
return "PlannerTranslator"
case .ordersDetail:
return "PlannerTranslator"
case .create:
return "PlannerTranslator"
case .edit:
return "PlannerTranslator"
}
}
// метод, который мы посылаем. Moya берёт все методы из Alamofire.
var method: Moya.Method {
switch self {
case .orders, .ordersDetail:
return Moya.Method.get
case .create:
return Moya.Method.post
case .edit:
return Moya.Method.patch
}
}
//1) кодировка параметров, также берётся из Alamofire.
//2) описание задач, которые буду выполняться
var task: Task {
switch self {
case .orders:
return .requestParameters(
parameters: ["maxRecords":20,
"view":"Order"],
encoding: URLEncoding.default)
case .ordersDetail(let id):
return .requestCompositeParameters(
bodyParameters: ["id" : id],
bodyEncoding: JSONEncoding.default,
urlParameters: [:])
case .create(let order):
do {
let dict = try MoyRequest(records:
[
SubMoyRequest<OrderItem>.init(
id: nil,
fields: order)
]).jsonData()
return .requestCompositeData(bodyData: dict,
urlParameters: [:])
} catch {
return Task.requestPlain
}
case .edit(let order):
do {
let dict = try MoyRequest(records:
[
SubMoyRequest<OrderItem>.init(
id: order.idAT,
fields: order)
]).jsonData()
return .requestCompositeData(bodyData: dict,
urlParameters: [:])
} catch {
return Task.requestPlain
}
}
}
var headers: [String : String]? {
let headersDictionary = MoyaNetworkManager.shared.headers
return headersDictionary
}
}
Для того чтобы найти адрес сервера, нужно открыть в документации authentication и скопировать ссылку.
MoyaNetworkManager.swift
Открываем https://airtable.com/account и в overview смотрим свой API-ключ:
Копируем его и вставляем в headersDictionary[ "Authorization" ] , не забыв написать Bearer перед ключом.
import Foundation
import Moya
final class MoyaNetworkManager {
// moyaProvider — это абстракция библиотеки, которая даёт доступ к запросам:
private var moyaProvider: AnyObject? = nil
var headers: [String : String] {
var headersDictionary = [String : String]()
headersDictionary["accept"] = "text/plain"
headersDictionary["content-type"] = "application/json; charset=utf-8"
// AirTable настоятельно советует хранить свои API-ключи при себе, поэтому не забудьте заменить звездочки на свой ключ
headersDictionary["Authorization"] = "Bearer *****************"
return headersDictionary
}
static let shared = MoyaNetworkManager()
func mainRequest<T: WDTargetType>(_ request: T,
withComplition completionHandler: @escaping (ResponseAPI) -> ()) {
let endpointClosure = { (target: T) -> Endpoint in
let defaultEndpoint = MoyaProvider.defaultEndpointMapping(for: target)
let url = (target.baseURL.absoluteString+target.path).removingPercentEncoding ?? ""
return Endpoint(url: url, sampleResponseClosure: defaultEndpoint.sampleResponseClosure,
method: target.method,
task: target.task,
httpHeaderFields: target.headers)
}
let provider = MoyaProvider<T>(endpointClosure: endpointClosure, stubClosure: MoyaProvider.neverStub)//, stubClosure: MoyaProvider.immediatelyStub)
self.moyaProvider = provider
//Выполняем запросы с помощью moyaProvider
provider.request(request) { result in
switch result {
case .success(let response):
completionHandler(ResponseAPI(statusCode: 0, data: response.data))
case .failure(let error):
completionHandler(ResponseAPI(withError: error))
}
}
}
}
OrdersModel.swift
Тут мы прописываем работу функций:
import Foundation
extension Encodable {
/// Encode into JSON and return `Data`
func jsonData() throws -> Data {
let encoder = JSONEncoder()
encoder.outputFormatting = .prettyPrinted
encoder.dateEncodingStrategy = .iso8601
return try encoder.encode(self)
}
}
class OrdersModel {
static func getDetailOfTask(
id: String,
completionHandler: @escaping (OrderItem) -> Void,
errorHandler: @escaping (WDNetworkError) -> Void) {
MoyaNetworkManager.shared.mainRequest(RequestType.ordersDetail(id)) { responseAPI in
parseData(responseAPI: responseAPI,
type: SubMoyResponse<OrderItem>.self,
completion: { response in
switch response {
case .success(let result):
completionHandler(result.fields)
case .failure(let error):
errorHandler(error)
}
})
}
}
static func create(_ order: OrderItem,
completionHandler: @escaping (OrderItem?) -> Void,
errorHandler: @escaping ( WDNetworkError) -> Void) {
MoyaNetworkManager.shared.mainRequest(RequestType.create(order)) { responseAPI in
parseData(responseAPI: responseAPI,
type: MoyResponse<OrderItem>.self,
completion: { response in
switch response {
case .success(let result):
completionHandler(result.records.compactMap({ $0.fields }).first)
case .failure(let error):
errorHandler(error)
}
})
}
}
static func edit(_ order: OrderItem,
completionHandler: @escaping (OrderItem?) -> Void,
errorHandler: @escaping ( WDNetworkError) -> Void) {
MoyaNetworkManager.shared.mainRequest(RequestType.edit(order)) { responseAPI in
parseData(responseAPI: responseAPI,
type: MoyResponse<OrderItem>.self,
completion: { response in
switch response {
case .success(let result):
completionHandler(result.records.compactMap({ $0.fields }).first)
case .failure(let error):
errorHandler(error)
}
})
}
}
static func loadTasks(
completionHandler: @escaping ([OrderItem]) -> Void,
errorHandler: @escaping ( WDNetworkError) -> Void) {
MoyaNetworkManager.shared.mainRequest(RequestType.orders) { responseAPI in
parseData(responseAPI: responseAPI,
type: MoyResponse<OrderItem>.self,
completion: { response in
switch response {
case .success(let result):
completionHandler(result.records.compactMap({ $0.fields }))
case .failure(let error):
errorHandler(error)
}
})
}
}
}
ResponseAPI.swift
Здесь мы расшифровываем данные (декодим).
После этого мы переходим к редактированию OrderItem :
import Foundation
struct SectionOrdersItem {
var orders: [OrderItem] = []
var date: Date
}
//Расширяем структуру протоколом ATProtocol, определенного в NetworkEntities
struct OrderItem: ATProtocol {
var idAT: String?
var summary: String?
var deadline: String?
var name: String = ""
var customer: String?
init(
idAT: String? = nil,
summary: String?,
deadline: String?,
name: String = "",
customer:String?) {
self.idAT = idAT
self.deadline = deadline
self.name = name
self.customer = customer
}
//Прописываем декодер
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: OrderKeys.self)
self.summary = try container.decodeIfPresent(String.self, forKey: .summary)
self.deadline = try container.decodeIfPresent(String.self, forKey: .deadline)
self.name = try container.decodeIfPresent(String.self, forKey: .name) ?? ""
self.customer = try container.decodeIfPresent(String.self, forKey: .customer) ?? ""
}
//Прописываем переменные для кодирования
enum OrderKeys: CodingKey {
case idAT
case summary
case deadline
case name
case customer
}
//Метод зашифровки данных
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: OrderKeys.self)
try container.encodeIfPresent(self.summary, forKey: .summary)
try container.encodeIfPresent(self.deadline, forKey: .deadline)
try container.encode(self.name, forKey: .name)
try container.encodeIfPresent(self.customer, forKey: .customer)
}
}
OrderAPI нам уже больше не нужен и все связанное с ним можно закомментировать. Запускаем и смотрим.
Данные берутся из-за AirTable, а не из OrderAPI. Все получилось!
Попробуем добавить задачу через приложение и посмотрим, отобразится ли она в общей базе AirTable:
Как мы видим, добавлять данные можно, даже пропуская некоторые параметры:
И аналогично добавлению, можно удалить заказ из AirTable и он не отобразится в общем списке.
В конце вы можете свериться с финальным проектом .
На этом все!
***
Комментарии