23 июля 2019
Хардкор

Как мы реализовали мобильное приложение с автоматической генерацией форм

Мы начали разрабатывать мобильное приложение для агентов, которые занимаются выездным оформлением страховых полисов. Они заполняют в приложении большие формы с данными клиентов: информация об автомобиле, собственниках, водителях и т.п. Хотя каждая форма имеет свои секции, ячейки и структуру, а каждый пункт анкеты требует уникального типа данных (строка, дата, вложенный документ), экранные формы были достаточно похожи. Но главное – это их количество… Никто не захочет заниматься повторением визуализации и обработки однотипных элементов помногу раз.

Чтобы избежать многочасовой ручной работы по созданию форм, надо применить немного сообразительности и много динамического построения UI. В этой статье мы хотим поделиться способом, как решили эту задачу.

 Для изящного решения задачи мы использовали механизм генерации объектов – ViewModels, которые используются для построения пользовательских форм с помощью таблиц.

В обычной работе для каждой отдельной таблицы, которую разработчик хочет увидеть на экране, должен быть создан свой класс ViewModel. Он определяет визуальную составляющую таблицы. Мы решили перейти на уровень выше и генерировать сами ViewModels и Models динамически, с помощью несложного описания структуры через поля Enum. 

 

 

Как это работает

Началось всё с enum’а. Для каждой анкеты мы создаем уникальный enum — это наши секции анкеты. Одним из его методов сделаем возврат массива ячеек данной секции.


Ячейками таблицы также будет enum с дополнительными функциями, которые будут описывать свойства ячеек. В таких функциях мы задаем название ячейки, начальное значение. Позже добавились такие параметры как  

  • проверка на отображение: некоторые ячейки должны быть скрытыми,
  • список родительских ячеек: ячейки, от которых зависит значение, валидация или отображение данной ячейки,
  • тип ячейки: простые ячейки со значениями, ячейки во switch, ячейки с функцией добавления элементов и т.д.

Подписываем все секции на общий протокол QuestionnaireSectionCellType, чтобы исключить привязку к конкретной секции, аналогично поступим и со всеми ячейками таблицы (QuestionnaireCellType).

 

protocol QuestionnaireSectionCellType {

var title: String { get }

var sectionCellTypes: [QuestionnaireCellType] { get }

}

 

protocol QuestionnaireCellType {

  var title: String { get }

  var initialValue: Any? { get }

  var isHidden: Bool { get }

  var parentFields: [QuestionnaireCellType] { get }

}

 

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


На примере экрана страхователя (enum c секциями — InsurantSectionType):

final class InsurantModel: BaseModel<QuestionnaireCellType> {

override init() {

  super.init()

initParameters()

} private func initParameters() {

  InsurantSectionType.allCases.forEach {

type in type.sectionCellTypes.forEach {

if let valueModel = ValueModel(type: $0,

parentFields: $0.parentFields,

value: $0.initialValue) {

valueModels.append(valueModel)

}

}

}

}

}

Готово! Теперь у нас есть таблица с начальными значениями. Добавим методы для чтение значения по ключу QuestionnaireCellType и для сохранения в нужный элемент массива.

У некоторых моделей могут быть необязательные поля, потому мы добавили массив с optional ключами. При валидации модели эти ключи могут не содержать значений, но модель будет считаться заполненной.

Далее для удобства все значения в ValueModel мы подписываем на общий протокол протокол StringRepresentable, чтобы ограничить список возможных значений и добавить метод для отображения значения в ячейке.

 

protocol StringRepresentable {

var stringValue: String? { get }

}

Функционал разрастался, и в моделях появилось множество других свойств и методов: очистка модели (в некоторые модели нужно выставлять начальные значения), поддержка динамического массива значений (value: Array), и т.д.


Этот подход оказался очень удобным для хранения в базе с помощью Realm. Для заполнения анкеты есть возможность выбрать ранее сохраненную заполненную модель. Для продления полиса ОСАГО агенту больше не нужно будет заполнять документы пользователя, прикрепленных им водителей, данные по ПТС по новой. Вместо этого можно просто переиспользовать для заполнения имеющуюся.

Для изменения или дополнения таблиц нужно всего лишь найти ViewModel, относящуюся к конкретному экрану, найти необходимый enum, отвечающий за отображение нужного блока и дополнить или исправить несколько case'ов. Всё, таблица примет необходимый вид!


Заполнение формы тестовыми значениями также оказалось очень удобным и быстрым. Таким образом можно быстро генерировать любые тестовые данные. А если добавить отдельный файл с начальными данными, откуда программа будет брать значение в каждое конкретное поле анкеты, то генерировать готовые анкеты сможет даже новичок, не вникая и не разбирая остальной код, кроме конкретного файла.
 

Зависимости


Отдельную задачу, которую мы решили в процессе разработки — это обработка зависимостей. Некоторые элементы анкеты были связаны между собой. Так, номер документа не может быть заполнен без выбора типа этого самого документа, номер дома не может быть указан без указания города и улицы и т.д. 

 

Мы сделали обновление значений анкеты с очисткой всех зависимых полей (Например, удалив или изменив тип документа, мы очищаем поле «номер документа»):

func updateValueModel(value: StringRepresentable?, for type: QuestionnaireCellType) {

guard let model = valueModels.first(where: { $0.type.equal(to: type) }) else {

return

}

 

model.value = value

clearRelativeValues(type: type)

}

 

func clearRelativeValues(type: QuestionnaireCellType) {

_ = valueModels.filter { $0.parentFields.contains(where: { $0.equal(to: type) }) }

.compactMap { $0.type }

.compactMap { updateValueModel(value: nil, for: $0) } }

 

Подводные камни, что приходилось решать в ходе разработки, и как мы справились

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

  • Экран с мощностью двигателя, который приходилось генерировать отдельно, из-за чего он отличался по функционалу. На этом экране должен уходить запрос и автоматически подставляться значение с сервера. Приходилось отдельно создавать для него класс, который бы отвечал за его отображение, загрузку, валидацию, подгрузку с сервера и подстановку значения в пустое поле, не мешая пользователю, если последний решит ввести своё значение.
  • Экран регистрационного номера, в котором единственном есть switch, влияющий на отображение или скрытие текстового поля. Для этого случая пришлось делать дополнительное условие, которой бы программно определяло случаи с включенным положением switch’а как пустое значение.
  • Динамические списки, такие как список водителей, которые приходилось хранить и привязывать к форме, что тоже выбивалось из концепции.
  • Уникальные типы валидации данных. Это могли быть и множество масок вперемешку с regex’ами. И валидация дат для различных полей, где валидация отличалась разительным образом (ограничения на минимальное/максимальное значения) и т.д.
  • Экраны для ввода данных сделаны как ячейки collectionView. (Того требовал дизайн!) Из-за этого, отображение модальных окон требовало чёткого контроля за выбранным индексом. Приходилось проверять поля, доступные для заполнения, и исключать из списка те, которые пользователь не должен видеть.
  • Для верного отображения данных в таблице нужно было внести изменения в методы модели некоторых экранов. Такие ячейки, как ФИО и адрес, отображаются в таблице одним элементом, но требуют несколько всплывающих экранов для полного заполнения.

Заключение

Этот опыт позволил нам быстро реализовать мобильное приложение, которое несложно поддерживать. Универсальность позволяет быстро генерировать таблицы с разными типами входных данных: мы сделали 20 окон всего за неделю. Также этот подход ускоряет процесс тестирования приложения. В ближайшем будущем мы будем переиспользовать готовую фабрику для быстрой генерации новых таблиц и нового функционала.