Share This
Связаться со мной
Крути в низ
Categories
//Пишем iOS-приложение для планирования задач с помощью AirTable, Moya и VIPER

Пишем iOS-приложение для планирования задач с помощью AirTable, Moya и VIPER

pishem ios prilozhenie dlja planirovanija zadach s pomoshhju airtable moya i viper 0e45495 - Пишем iOS-приложение для планирования задач с помощью AirTable, Moya и VIPER

iOS-developer, ИТ-переводчица, пишу статьи и гайды. В этой статье мы создадим iOS-приложение для планирования задач и воспользуемся AirTable в качестве бесплатного онлайн-сервиса для удаленного хранения данных.

pishem ios prilozhenie dlja planirovanija zadach s pomoshhju airtable moya i viper 74e9da9 - Пишем iOS-приложение для планирования задач с помощью AirTable, Moya и VIPER

Я пишу и перевожу статьи уже 2.5 года, и в какой-то момент я осознала, что мне не хватает приложения под мои нужды: следить за дедлайнами по сдаче статей, хранить информацию о заказчиках, вести смету, держать под рукой визитку с контактами и т. д. Поэтому я решила создать приложение, которое облегчит планирование моих задач.

В ходе реализации мне потребовался бесплатный (или условно бесплатный) онлайн-сервис для хранения данных удаленно, и тогда коллега рассказала мне про Airtable. Однако в интернете мною не были найдены какие-либо статьи по работе с ним (только упоминания), в связи с чем появилась идея написать статью по работе с AirTable для начинающих разработчиков.

AirTable позволяет достаточно просто интегрировать данные в проект. API точно следует семантике REST, использует JSON для кодирования объектов и полагается на стандартные коды HTTP для уведомления о результатах операции. Для работы с сетевым слоем будем использовать Moya, достаточно востребованный и легкий фреймворк.

В интернете полно обучающих статей на самые разные темы, реализованных на простейших архитектурах (MVC и т.п.), но на VIPER-е их не так много. При этом VIPER зачастую спрашивают на собеседованиях даже у джунов. Поэтому приложение напишем с использованием этой архитектуры.

Сначала мы рассмотрим простое приложение на VIPER, а затем пошагово добавим AirTable и Moya. В рамках знакомства я упростила код уже готового проекта, убрав лишние модули и переменные, чтобы это не помешало знакомству с обозначенными выше темами.

VIPER

За идею реализации архитектуры VIPER был взят вариант пользователя Alfian Losari. Данная реализация отлично подойдет для знакомства с VIPER, код понятен, его легко читать и масштабировать. Советую ознакомиться с подробной теорией в видео.

Стандартная схема VIPER выглядит так:

pishem ios prilozhenie dlja planirovanija zadach s pomoshhju airtable moya i viper 0b92efd - Пишем iOS-приложение для планирования задач с помощью AirTable, Moya и VIPER

Изображение взято отсюда.

Архитектура VIPER состоит из следующих компонентов:

Entity – отвечает за хранение сущностей (например, у нас это сущность «OrderItem», в которой хранятся заказы).

Interactor – посредник между Entity (сущностями) и Presenter. Бизнес-логика приложения хранится здесь.

Presenter – своеобразный мост между всеми важными частями VIPER (кроме Entity). С одной стороны, он получает на входе события, поступающие из View, и реагирует на них, запрашивая данные у Interactor. С другой стороны, он получает данные, поступающие от Interactor, применяет логику представления к этим данным, и, наконец, сообщает View, что отображать. При этом Presenter ничего не знает про UIKit.

View – представление, которое отвечает за отображение и ничего не знает про данные. Связь только с Presenter.

Router – отвечает за навигационную логику, когда и какие экраны отображаются.

Скачайте стартовый проект по ссылке. Проект состоит из двух модулей: OrderListModule и OrderDetailModule.

OrderListModule представляет собой набор классов для отображения и загрузки списка статей:

pishem ios prilozhenie dlja planirovanija zadach s pomoshhju airtable moya i viper 2c2bf57 - Пишем iOS-приложение для планирования задач с помощью AirTable, Moya и VIPER

OrderDetailModule представляет собой информацию по каждой статье:

pishem ios prilozhenie dlja planirovanija zadach s pomoshhju airtable moya i viper 6b90869 - Пишем iOS-приложение для планирования задач с помощью AirTable, Moya и VIPER

Каждый модуль включает в себя View, Interactor, Presenter, Entity, Router, а также необходимые протоколы для сообщения между частями модулей.

Entity отвечает за сущности и является общим для обоих модулей:

pishem ios prilozhenie dlja planirovanija zadach s pomoshhju airtable moya i viper 4e0715b - Пишем iOS-приложение для планирования задач с помощью AirTable, Moya и VIPER

В 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     

Если у вас все хорошо, то в терминале у вас должно появиться:

pishem ios prilozhenie dlja planirovanija zadach s pomoshhju airtable moya i viper 888ed5b - Пишем iOS-приложение для планирования задач с помощью AirTable, Moya и VIPER

Далее мы открываем файл с расширением .xcworkspace (не xcodeproj!), иначе проект не запустится:

pishem ios prilozhenie dlja planirovanija zadach s pomoshhju airtable moya i viper 452621e - Пишем iOS-приложение для планирования задач с помощью AirTable, Moya и VIPER

Все хорошо, мы подключили Moya!

Теперь перейдем к работе с AirTable.

Подключаем AirTable

  1. Регистрируемся на сайте https://airtable.com/
  2. Создаем таблицу (имена переменных проекта и столбцов совпадают) и заполняем ее:

pishem ios prilozhenie dlja planirovanija zadach s pomoshhju airtable moya i viper 1add861 - Пишем iOS-приложение для планирования задач с помощью AirTable, Moya и VIPER

Обратите внимание, что у каждого столбца свой тип данных. Тип данных можно посмотреть через Edit field:

pishem ios prilozhenie dlja planirovanija zadach s pomoshhju airtable moya i viper 164bf5b - Пишем iOS-приложение для планирования задач с помощью AirTable, Moya и VIPER

У name, deadline, summary и customer – это Single line text:

pishem ios prilozhenie dlja planirovanija zadach s pomoshhju airtable moya i viper d845743 - Пишем iOS-приложение для планирования задач с помощью AirTable, Moya и VIPER

У deadline – это Date.

pishem ios prilozhenie dlja planirovanija zadach s pomoshhju airtable moya i viper 0848ae5 - Пишем iOS-приложение для планирования задач с помощью AirTable, Moya и VIPER

После создания таблицы приступаем к добавлению файлов в проект.

Сначала добавим новую сущность 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 файла:

pishem ios prilozhenie dlja planirovanija zadach s pomoshhju airtable moya i viper 763500a - Пишем iOS-приложение для планирования задач с помощью AirTable, Moya и VIPER

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 и скопировать ссылку.

pishem ios prilozhenie dlja planirovanija zadach s pomoshhju airtable moya i viper 0246e69 - Пишем iOS-приложение для планирования задач с помощью AirTable, Moya и VIPER

MoyaNetworkManager.swift

Открываем https://airtable.com/account и в overview смотрим свой API-ключ:

pishem ios prilozhenie dlja planirovanija zadach s pomoshhju airtable moya i viper 96227fd - Пишем iOS-приложение для планирования задач с помощью AirTable, Moya и VIPER

Копируем его и вставляем в 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 нам уже больше не нужен и все связанное с ним можно закомментировать. Запускаем и смотрим.

pishem ios prilozhenie dlja planirovanija zadach s pomoshhju airtable moya i viper ee007a2 - Пишем iOS-приложение для планирования задач с помощью AirTable, Moya и VIPER

Данные берутся из-за AirTable, а не из OrderAPI. Все получилось!

Попробуем добавить задачу через приложение и посмотрим, отобразится ли она в общей базе AirTable:

pishem ios prilozhenie dlja planirovanija zadach s pomoshhju airtable moya i viper f2d2b7b - Пишем iOS-приложение для планирования задач с помощью AirTable, Moya и VIPER

Как мы видим, добавлять данные можно, даже пропуская некоторые параметры:

pishem ios prilozhenie dlja planirovanija zadach s pomoshhju airtable moya i viper 18c6c93 - Пишем iOS-приложение для планирования задач с помощью AirTable, Moya и VIPER

И аналогично добавлению, можно удалить заказ из AirTable и он не отобразится в общем списке.

В конце вы можете свериться с финальным проектом.

На этом все!

*** Больше полезных материалов вы найдете на нашем телеграм-канале «Библиотека мобильного разработчика» Интересно, перейти к каналу

  • 0 views
  • 0 Comment

Leave a Reply

Ваш адрес email не будет опубликован. Обязательные поля помечены *

Этот сайт использует Akismet для борьбы со спамом. Узнайте, как обрабатываются ваши данные комментариев.

Связаться со мной
Close