Galina Iaroshenko iOS-developer, ИТ-переводчица, пишу статьи и гайды. В этой статье мы создадим iOS-приложение для планирования задач и воспользуемся AirTable в качестве бесплатного онлайн-сервиса для удаленного хранения данных. Я пишу и перевожу статьи уже 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 и он не отобразится в общем списке. В конце вы можете свериться с финальным проектом. На этом все! *** Больше полезных материалов вы найдете на нашем телеграм-канале «Библиотека мобильного разработчика» Интересно, перейти к каналу
iOS-developer, ИТ-переводчица, пишу статьи и гайды. В этой статье мы создадим iOS-приложение для планирования задач и воспользуемся AirTable в качестве бесплатного онлайн-сервиса для удаленного хранения данных. Я пишу и перевожу статьи уже 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 и он не отобразится в общем списке. В конце вы можете свериться с финальным проектом. На этом все! *** Больше полезных материалов вы найдете на нашем телеграм-канале «Библиотека мобильного разработчика» Интересно, перейти к каналу
Я пишу и перевожу статьи уже 2.5 года, и в какой-то момент я осознала, что мне не хватает приложения под мои нужды: следить за дедлайнами по сдаче статей, хранить информацию о заказчиках, вести смету, держать под рукой визитку с контактами и т. д. Поэтому я решила создать приложение, которое облегчит планирование моих задач.
В ходе реализации мне потребовался бесплатный (или условно бесплатный) онлайн-сервис для хранения данных удаленно, и тогда коллега рассказала мне про Airtable. Однако в интернете мною не были найдены какие-либо статьи по работе с ним (только упоминания), в связи с чем появилась идея написать статью по работе с AirTable для начинающих разработчиков.
AirTable позволяет достаточно просто интегрировать данные в проект. API точно следует семантике REST, использует JSON для кодирования объектов и полагается на стандартные коды HTTP для уведомления о результатах операции. Для работы с сетевым слоем будем использовать Moya, достаточно востребованный и легкий фреймворк.
В интернете полно обучающих статей на самые разные темы, реализованных на простейших архитектурах (MVC и т.п.), но на VIPER-е их не так много. При этом VIPER зачастую спрашивают на собеседованиях даже у джунов. Поэтому приложение напишем с использованием этой архитектуры.
Сначала мы рассмотрим простое приложение на VIPER, а затем пошагово добавим AirTable и Moya. В рамках знакомства я упростила код уже готового проекта, убрав лишние модули и переменные, чтобы это не помешало знакомству с обозначенными выше темами.
За идею реализации архитектуры 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) } }
Создайте файл 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.
Обратите внимание, что у каждого столбца свой тип данных. Тип данных можно посмотреть через 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 файла:
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 и скопировать ссылку.
Открываем 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)) } } } }
Тут мы прописываем работу функций:
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) } }) } } }
Здесь мы расшифровываем данные (декодим).
После этого мы переходим к редактированию 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 и он не отобразится в общем списке.
В конце вы можете свериться с финальным проектом.
На этом все!
*** Больше полезных материалов вы найдете на нашем телеграм-канале «Библиотека мобильного разработчика» Интересно, перейти к каналу
Ваш адрес email не будет опубликован. Обязательные поля помечены *
Сохранить моё имя, email и адрес сайта в этом браузере для последующих моих комментариев.
Δ
Этот сайт использует Akismet для борьбы со спамом. Узнайте, как обрабатываются ваши данные комментариев.