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 и он не отобразится в общем списке.

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

На этом все!

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

МЕРОПРИЯТИЯ

Комментарии

ВАКАНСИИ

Добавить вакансию
Product Manager
Москва, по итогам собеседования
Data Scientist
Москва, по итогам собеседования
Middle Data Scientist
Москва, по итогам собеседования

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