22 февраля 2023

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

iOS-developer, ИТ-переводчица, пишу статьи и гайды.
В этой статье мы создадим iOS-приложение для планирования задач и воспользуемся AirTable в качестве бесплатного онлайн-сервиса для удаленного хранения данных.
📱Пишем 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 выглядит так:

📱Пишем 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 представляет собой набор классов для отображения и загрузки списка статей:

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

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

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

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

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

📱Пишем 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
    

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

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

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

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

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

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

Подключаем AirTable

  1. Регистрируемся на сайте https://airtable.com/
  2. Создаем таблицу (имена переменных проекта и столбцов совпадают) и заполняем ее:
📱Пишем iOS-приложение для планирования задач с помощью AirTable, Moya и VIPER

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

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

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

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

У deadline – это Date.

📱Пишем 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 файла:

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

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

MoyaNetworkManager.swift

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

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

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

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

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

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

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

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

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

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

На этом все!

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

МЕРОПРИЯТИЯ

Комментарии

ВАКАНСИИ

Добавить вакансию
Разработчик С#
от 200000 RUB до 400000 RUB
Senior Java Developer
Москва, по итогам собеседования

ЛУЧШИЕ СТАТЬИ ПО ТЕМЕ