Главная » Хабрахабр » Вы просто не умеете редактировать данные

Вы просто не умеете редактировать данные

А что насчёт данных в мобильном приложении, когда на экране нужно разместить большое количество элементов? Конечно, вы постоянно что-то редактируете и прекрасно умеете это делать. Задача перестаёт быть тривиальной. Не забудьте, что сделать это нужно максимально аккуратно для пользователя, эффективность которого напрямую зависит от удобства ввода.

Прочитав статью, вы узнаете, как:

  • организовать структуру данных, чтобы их было удобно редактировать
  • обеспечить «динамизм» вашему UI
  • определять, изменилось ли что-то
  • сохранять историю изменений
  • сделать многопользовательский режим за 5 минут

В конце вас ждет готовый прототип с исходным кодом, демонстрирующим описанный подход.

Один из инструментов, обеспечивающих это преимущество — обход территории, в прямом смысле, «ногами». Мы в 2ГИС стремимся к максимально точным и актуальным данным. В полях наши специалисты выверяют данные по карте и справочнику, а также собирают массу данных об организации.

Ситуация усложняется следующими требованиями:

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

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

Готовим «удобные» данные

Следовательно, без read /write хранилища не обойтись. Данные нам нужно не только показывать, но и редактировать. Нам подходит SQLite — он отлично работает под Android и включает всю необходимую функциональность.

Причём, любой объект, будь то дом, или фирма, мы описали просто набором «филдов» (или атрибутов), так что типовой объект стал выглядеть так: Для обеспечения удобного расширения данных и однотипной работы с различными объектами решено было использовать JSON для хранения.

, { "code": "NameDescription", "value": "городской информационный сервис" } ] }

Более сложные данные для удобства сериализации/десериализации хранятся полноценными объектами и выглядит это так: В самом простом случае, значения у нас хранятся строкой: True / False, число, дата, диапазон чисел и дат.

public class FieldDto { @SerializedName("code") private String code; // Код атрибута @SerializedName("value") private String value; // Значение атрибута строкой @SerializedName("r_values") private List<Long> referenceValues; // Значение атрибута через ссылки на справочник @SerializedName("change_info") private ChangeInfoDto changeInfo; // Информация об изменении (где и когда) // EntityState @SerializedName("state") public int State; // Статус (не измененный, новый, измененный, удаленный) public List<Long> getReferenceValues() { return this.referenceValues; } public void setChangeInfo(ChangeInfoDto changeInfo) { this.changeInfo = changeInfo; } public ChangeInfoDto getChangeInfo() { return this.changeInfo; } @Override public boolean equals(Object o) { if (o == null) return false; if (o == this) return true; if (!(o instanceof FieldDto)) return false; FieldDto other = (FieldDto) o; return this.code.equals(other.code); } public boolean isEmpty() { return StringHelperKt.isNullOrEmpty(this.value) && (this.referenceValues == null || this.referenceValues.isEmpty()); } public void setRemovedState() { State = EntityState.STATE_REMOVED; } }

У нас есть атрибут, однозначно определяемый своим кодом. Идея в следующем. Кроме того, мы умеем сравнивать значения двух атрибутов (метод equals). У него есть какое-то значение, которое может быть, а может отсутствовать (метод isEmpty).

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

Вы наверняка обратили внимание на атрибуты «state» и «change_info» — они как раз понадобятся нам для понимания того, изменилось ли значение филда, где и когда он был изменен.

Этого вполне достаточно для описания любой нашей сущности: дом, вход в дом, забор, достопримечательность, фирма.

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

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

public class FieldSetting { public static final int TYPE_TEXT = 1; // список с единичным выбором public static final int TYPE_BOOL_SINGLE = 2; //список с множественным выбором public static final int TYPE_BOOL_MULTY = 3; // True или False public static final int TYPE_BOOL_SIMPLE = 13; public static final int TYPE_INT = 4; public static String ATTRIBUTE_START_GROUP = "start_group"; public static String ATTRIBUTE_END_GROUP = "end_group"; private final long id; private final int type; private final String name; private final Long parentId; private final String parentName; private final String fieldCode; private final String referenceCode; public FieldSetting(long id, int type, String name, String parent_name, Long parentId, String fieldCode, String referenceCode) { this.id = id; this.type = type; this.name = name; this.parentName = parent_name; this.parentId = parentId; this.fieldCode = fieldCode; this.referenceCode = referenceCode; } public int getType() { return type; } public String getName() { return name; } public Long getParentId() { return parentId; } public String getParentName() { return parentName; } public String getFieldCode() { return fieldCode; } public String getReferenceCode() { return referenceCode; } public long getId() { return id; }
}

Упрощенно можно представить это так:

Организуем простое хранилище

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

Но кое-какие связи между объектами нам все-таки потребуются. Решение о хранении данных в JSON сразу же упрощает нам жизнь с точки зрения СУБД: нет нужды делать таблицы под каждую сущность, мы фактически работаем в терминах «документа». Например, чтобы показать список всех организаций в здании.

Но в общем случае без таблицы связей не обойтись. В случае структуры данных 2ГИС, в 90% хватает связи родитель-потомок, поэтому проще всего её разместить прямо в самом объекте.

Итоговая структура таблиц будет выглядеть примерно так:

-- Данные по объектам
CREATE TABLE object_data ( id INTEGER NOT NULL, -- ID объекта type INTEGER NOT NULL, -- Тип объекта (здание, фирма и т.д.) attributes TEXT, -- Атрибуты в JSON формате parent_id INTEGER, -- ID родительского объекта (например, здание для входа) PRIMARY KEY ( id )
); -- История изменений объекта
CREATE TABLE object_data_history ( id INTEGER NOT NULL, -- ID объекта version INTEGER NOT NULL, -- Версия объекта type INTEGER NOT NULL, -- Тип объекта attributes TEXT, -- Атрибуты в JSON формате parent_id INTEGER, -- ID родительского объекта PRIMARY KEY ( id, version )
); -- Конфигурация атрибутов
CREATE TABLE field_settings ( id INTEGER NOT NULL, -- ID атрибута field_code TEXT, -- Код атрибута object_type INTEGER NOT NULL, -- Тип объекта (здание, фирма и т.д.) type INTEGER NOT NULL, -- Тип атрибута (Строка, число, дата и т.д.) name TEXT, -- Название атрибута reference_code TEXT -- Код справочника PRIMARY KEY ( id )
); -- Значения справочников
CREATE TABLE reference_items ( id INT NOT NULL, -- ID значения справочника ref_code TEXT NOT NULL, -- Код справочника code INTEGER NOT NULL, -- Код значения справочника name TEXT NOT NULL, -- Название sortpos INTEGER NOT NULL, -- Сортировка PRIMARY KEY ( id )
); -- Связи
CREATE TABLE relations ( parent_id INTEGER NOT NULL, -- ID родителя child_id INTEGER NOT NULL, -- ID потомка type INTEGER NOT NULL -- Тип связи
); -- Шаблоны редактирования (об этом ниже)
CREATE TABLE template_data ( id INTEGER NOT NULL, type INTEGER NOT NULL, name TEXT NOT NULL, json TEXT NOT NULL, PRIMARY KEY (id, type)
); -- Полнотекстовый поиск
CREATE VIRTUAL TABLE search_data USING fts5(content="", name)

В простом кейсе этого достаточно для реализации задекларированных в начале статьи требований. Вот и всё.

Для экономии места, которого потребуется довольно много, лучше хранить его как BLOB и сжимать при сохранении. Обращаю ваше внимание на то, что JSON в object_data.attributes хранится как текст.

Правда, он платный. Альтернативный простой вариант — воспользоваться плагином для Sqlite, который позволяет не только сжимать, но еще и шифровать данные.

Она полностью дублирует object_data, добавляя лишь версию объекта, и позволяет сохранять историю изменений с нужной «глубиной». Внимательный читатель наверняка обратил внимание на таблицу object_data_history. Кроме того, история будет полезна для определения факта изменения объекта. При желании, можно будет не только показать на экране данные без каких-то существенных доработок логики, но и легко «откатить» состояние объекта на нужную версию.

Делаем «хороший» поиск

И тут нам снова поможет SQLite, который предоставляет хоть и ограниченный, но, тем не, менее full text search (FTS). За бортом у нас остался только поиск. Старые добрые индексы, кстати, никуда не делись и так же будут полезны.

В поставку SQLite Андроида расширение FTS не включено. Тут есть один нюанс. Поэтому придется либо найти уже готовую сборку со всем необходимым, либо собрать самостоятельно, что совсем не сложно. Кроме того, в библиотеке есть и другие полезные расширения, которые вам могут понадобиться. Дальше останется только подключить полученный aar к своему проекту и заменить все ссылки с android.database.sqlite. Для этого читаем мануал и делаем все по шагам. SQLiteDatabase. SQLiteDatabase на ваш пакет org.sqlite.database.sqlite.

Использование content-less таблиц (см. Но вернемся к FTS. Причем индекс можно обновлять на лету при редактировании данных или добавлении новых, так что все сразу становится поискабельным. таблицу search_data) позволяет построить полнотекстовый индекс без дублирования данных, и мы спокойно можем организовать поиск по названию фирмы, её контактам и рубрикам. Огонь!

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

Пример фильтров, которые используются в нашем приложении, выглядит так:

Таким образом, любая вьюха, на которой мы будем отображать поля для ввода данных, должна содержать layout, в который можно добавлять детей, например:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" xmlns:app="http://schemas.android.com/apk/res-auto"> ... <android.support.v4.widget.NestedScrollView android:layout_width="match_parent" android:layout_height="match_parent" android:fillViewport="true" > <LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" > <LinearLayout android:id="@+id/fev_content" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="20dp" android:orientation="vertical"> </LinearLayout> ... </LinearLayout> </android.support.v4.widget.NestedScrollView> </LinearLayout>

Реализация добавления будет выглядеть так:

vContent = this.mainView.findViewById(R.id.fev_content); @Override
public void addLayoutView(DynamicView view) { vContent.addView((View) view.getView());
}

Вводим абстракцию DynamicView, которая позволяет нам скрыть детали реализации конкретного контрола для редактирования какого-то типа атрибута:

interface DynamicView { val view: Any val fieldCode: String fun getViewGroup(): DymamicViewGroup? fun setViewGroup(viewGroup: DymamicViewGroup) fun getValue(): FieldDto? fun setValue(value: FieldDto) fun hasChanges(): Boolean fun setTemplate(templateItem: EditObjectTemplateDto.ItemDto)
}

Нам же хочется иметь возможность прямо на лету менять UI в зависимости от каких-то критериев. И сразу же вводим понятие шаблона и элемента шаблона, который как раз нужен нам для поддержки «динамичности». Для этого воспользуемся описанием шаблона в JSON:

{ "items": [{ "field_code": "photos" }, { "field_code": "start_group", "group_name": "Фирма", "bold_caption": true, "show_header": true }, { "caption": "Название", "field_code": "c_name" }, { "field_code": "NameDescription" }, { "field_code": "OrganizationLegalForm" }, { "caption": "Юр. название", "field_code": "LegalName" }, { "field_code": "end_group" }, { "field_code": "c_address_name", "validator": "required" }, { "field_code": "start_group", "layout_orientation": "horizontal" }, { "field_code": "ref_point" }, { "edit_control_type": 18, "field_code": "Floor" }, { "field_code": "end_group" }, { "field_code": "AddressDescription" }, { "edit_control_type": 16, "field_code": "loc_verification" }, { "field_code": "end_group" } ] }

При необходимости можно объединять контролы в группы, используя start_group / end_group, выделять заголовки bold_caption или скрывать их полностью с помощью show_header. Шаблоном определяется перечень и порядок атрибутов, которые мы будем редактировать на экране.

Он определяет тип контрола, которым будет редактироваться атрибут. Есть еще один важный тег — edit_control_type. Например, если нам в одном случае хочется видеть радиокнопки, а в другом — использовать switch для редактирования булевых атрибутов.

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

Объединяем всё вместе

Он будет заниматься созданием контролов для редактирования каждого атрибута, расположением их на экране, отображением данных объекта в эти контролы и сохранением изменений в базу. Магию по формированию вьюхи разместим в презентере.

abstract class DynamicViewPresenterBase<TDto : SimpleDto, TView : DynamicViewContainer> { private lateinit var views: MutableList<DynamicView> private lateinit var template: EditObjectTemplateDto internal lateinit var containerView: TView internal lateinit var dto: TDto private val removedFields = HashSet<String>() fun init(dto: TDto, containerView: TView) { this.containerView = containerView this.dto = dto val configuration = getFieldsConfiguration(fieldsConfigurationService) this.template = editTemplate this.views = ArrayList(configuration.size) addDynamicViews(configuration) onInit(configuration) } private fun addDynamicViews(configuration: List<FieldConfiguration>) { val groupStack = Stack<DymamicViewGroup>() var lastViewGroup: DymamicViewGroup? = null val processedFields = HashSet<String>(10) for (templateItem in this.template.Items) { ... val config = getFieldConfiguration(configuration, templateItem.FieldCode) ?: continue processedFields.add(config.fieldCode) processField(lastViewGroup, templateItem, config) } } private fun processField(lastViewGroup: DymamicViewGroup?, templateItem: EditObjectTemplateDto.ItemDto, config: FieldConfiguration) { val view = getDynamicView(templateItem, config) views.add(view) if (lastViewGroup != null) { lastViewGroup.addView(view) } else { this.containerView.addLayoutView(view) } } private fun getDynamicView(templateItem: EditObjectTemplateDto.ItemDto, config: FieldConfiguration): DynamicView { val view = fieldViewFactory.getView(this.containerView, config, templateItem) val field = this.dto.getField(config.fieldCode) if (field != null) { view.setValue(field) } view.setTemplate(templateItem) return view } fun onOkClick() { var initialDto: TDto? = null val beforeSaveDtoCopy = dtoCopy try { if (!dto.IsInAddingMode()) { initialDto = getInitialDto() } } catch (e: DataContextException) { onGetInitialDtoError(e) return } val fields = getFieldFromView(initialDto) dto.setFields(fields, removedFields) dto.changeInfo = locationManager.changeInfo fillRemovedFields(dto, initialDto) try { val hasChanges = dto.IsInAddingMode() || initialDto != dto if (hasChanges || beforeSaveDtoCopy != null && beforeSaveDtoCopy != dto) { if (!hasChanges) { dto.changeInfo = null } saveObject(dto, initialDto) } else { undoChanges(dto, initialDto) } afterSaveObject() } catch (e: DataContextException) { onSaveError(e) } } fun undoChanges() { try { if (!dto.IsInAddingMode()) { val initialDto = getInitialDto() undoChanges(dto, initialDto) closeView() } } catch (e: DataContextException) { onGetInitialDtoError(e) } } abstract fun closeView() fun onBackButtonClick() { var hasChanges = false for (value in views) { if (value.fieldCode == FieldSetting.FIELD_START_GROUP) { continue } if (value.hasChanges()) { hasChanges = true break } } if (!hasChanges) { closeView() return } containerView.showCloseAlertDialog() } ...
}

Это перечень доступных для данного типа объекта атрибутов. В методе init получаем конфигурацию методом getFieldsConfiguration. Затем берём шаблон редактирования, который определяет вид экрана, и в методе addDynamicViews создаем все контролы и добавляем их через addLayoutView в родительский layout.

При сохранении изменений в методе onOkClick мы обращаемся к исходному состоянию объекта, чтобы определить, изменилось ли что-то в текущем состоянии, а затем к таблице с историей object_data_history, чтобы определить, изменилось ли что-то относительно самой первой его версии.

Если текущее состояние не изменилось — ничего не делаем.

Если вернулись к к первой версии —откатываем изменения.

Если это новое состояние — обновляем object_data_history.

И всё это достигается простой проверкой на равенство атрибутов объекта. Если пользователь нажал «бек» — с помощью этого же механизма можем его предупредить о необходимости сохранения изменений.

Реализация редактирования строкового атрибута

От нас требуется реализовать DynamicView. Рассмотрим, как выглядит реализация контрола для редактирования строкового атрибута. Давайте взглянем на код:

open class EditTextFactory internal constructor(commonServices: UICommonServices) : ViewFactoryBase(commonServices) { override fun getView(container: DynamicViewContainer, configuration: FieldConfiguration): DynamicView { val mainView = getMainView(inflater) val editText = mainView.findViewById<EditText>(R.id.det_main_text) ... val dynamicView = object : DynamicEditableViewBase(locationManager) { override val view: View get() = mainView override val currentValue: FieldDto get() = FieldDto(configuration.fieldCode, editText.text.toString()) override val fieldCode: String get() = configuration.fieldCode override fun setValue(value: FieldDto) { initialValue = value editText.setText(value.value) } } editText.setOnFocusChangeListener { v, hasFocus -> if (!hasFocus) { dynamicView.rememberChangeLocation() } } return dynamicView }
}

Мы создаем EditText, в методе setValue устанавливаем в него значение, в методе getCurrentValue возвращаем текущее значение и подписываемся на событие потери фокуса, чтоб запомнить, где и когда пользователь его менял. Все довольно тривиально.

rememberChangeLocation фиксирует дату и место изменения. В базовой реализации метод setInitialValue предназначен для сохранения исходного значения. А метод setChangeInfo делает всю магию: сравнивает текущее значение атрибута с исходным, определяет статусы изменения атрибута и устанавливает changeInfo.

Пример дизайна некоторых из них представлен ниже. Остальные контролы реализованы аналогично, нет смысла рассматривать их дополнительно.

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

Причем набор тегов определяется типом объекта, а сами теги могут соответствовать атрибутам, хотя и не обязательно. Мы воспользовались простым решением — добавили к фоткам теги.

На экране это выглядит так:

Специалист в офисе сможет по фото внести все необходимые данные в систему.
Пользователь фотографирует, указывает нужные теги. Это в наш век космических кораблей делается элементарно прямо на девайсе. Можно пойти дальше и сделать автоматическое распознавание нужной информации на картинке, например, найти ИНН или ОГРН.

Итоговая структура взаимосвязей выглядит так:

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

Достаточно сделать шаблон для редактирования фирмы и для её просмотра. Можно развить реализацию и дальше, используя тот же самый механизм для просмотра информации. Будет два шаблона и два экрана, но оба будут формироваться динамически с помощью описанного выше подхода.

Многопользовательский режим за пять минут

В начале статьи я обещал показать, как сделать его за пять минут. Давайте теперь поговорим про многопользовательский режим. Смотрите, с точки зрения подготовки данных наша реальность выглядит так: И тут нам опять поможет фишка SQLite.

На беке есть экспорт, который собирает данные со всех внутренних систем, нарезает их по «регионам» и выгружает все необходимое region.sqlite — все атрибуты по геообъектам и по фирмам.

Если с одним устройством работают разные люди, то они используют одни и те же данные «региона». «Регионы» пользователь скачивает себе на телефон — это общие для всех данные.

Эти данные мы фиксируем в базе пользователя user.sqlite. Работа, которую они делают по сбору новых организаций и актуализации существующих, должна быть персонифицирована: у каждого пользователя свои задачи и свои результаты их выполнения. Чтобы их было удобно выгребать из базы и делать джойны с данными региона, пользуемся командой
ATTACH database в SQLIte.

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

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

Про доставку обновлений с сервера

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

Для получения обновлений достаточно передать эту версию и получить все изменения и удаления. Это означает, что нам нужно всего одно число — максимальная версия данных на момент выгрузки, которую мы сохраняем в базу региона. Метод API у нас выглядит примерно так:

/api/regionchanges?region_id&entity_type&max_version

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

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

Смело используйте:

  • хранение атрибутов в JSON
  • построение UI на основе шаблона
  • фишки SQLite (attach database, FTS и другие экстеншены)

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

Исходный код к статье с готовым примером реализации можно посмотреть на гитхабе.


Оставить комментарий

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

*

x

Ещё Hi-Tech Интересное!

Иллюзия обмана: визуально-оптическая иллюзия на базе ретропрогнозирования

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

[Перевод] Chrome 70 поддерживает [список фич] и AV1 – почему поддержка этого кодека так важна?

69-я версия Chrome была большииииим обновлением, т.к. показала новый интерфейс для десктопной и мобильной версий. Chrome 70 не столь радикален, но его новые фичи весьма важны. Мы сделали адаптированный перевод и добавили материал про самое, на наш взгляд, важное в ...