Конечно, вы постоянно что-то редактируете и прекрасно умеете это делать. А что насчёт данных в мобильном приложении, когда на экране нужно разместить большое количество элементов? Не забудьте, что сделать это нужно максимально аккуратно для пользователя, эффективность которого напрямую зависит от удобства ввода. Задача перестаёт быть тривиальной.
Прочитав статью, вы узнаете, как:
- организовать структуру данных, чтобы их было удобно редактировать
- обеспечить «динамизм» вашему UI
- определять, изменилось ли что-то
- сохранять историю изменений
- сделать многопользовательский режим за 5 минут
В конце вас ждет готовый прототип с исходным кодом, демонстрирующим описанный подход.
Мы в 2ГИС стремимся к максимально точным и актуальным данным. Один из инструментов, обеспечивающих это преимущество — обход территории, в прямом смысле, «ногами». В полях наши специалисты выверяют данные по карте и справочнику, а также собирают массу данных об организации.
Ситуация усложняется следующими требованиями:
- нужно добавлять фото к любому атрибуту обрабатываемого объекта;
- знать, где и когда пользователь редактировал конкретный атрибут;
- уметь откатывать изменения при необходимости;
- добавлять новые атрибуты без изменения приложения;
- собирать только необходимый для данной задачи перечень данных;
- искать фирмы по различным критериям и делать это быстро.
Кроме того, требуется предусмотреть краудсорсинговые сценарии и возможность многопользовательской работы на одном устройстве, так как разные специалисты могут пользоваться одним девайсом.
Готовим «удобные» данные
Данные нам нужно не только показывать, но и редактировать. Следовательно, без read /write хранилища не обойтись. Нам подходит SQLite — он отлично работает под Android и включает всю необходимую функциональность.
Для обеспечения удобного расширения данных и однотипной работы с различными объектами решено было использовать JSON для хранения. Причём, любой объект, будь то дом, или фирма, мы описали просто набором «филдов» (или атрибутов), так что типовой объект стал выглядеть так:
{
"fields": [
{
"code": "BrandName",
"value": "2ГИС"
},
{
"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;
}
}
Идея в следующем. У нас есть атрибут, однозначно определяемый своим кодом. У него есть какое-то значение, которое может быть, а может отсутствовать (метод isEmpty). Кроме того, мы умеем сравнивать значения двух атрибутов (метод equals).
Сложные атрибуты типа времени работы в нашей реализации выделены отдельными свойствами. Таких атрибутов мало, так что напрягать своим постоянным появлением они не будут. Основная же масса атрибутов укладывается в простую строку или в ссылки на справочник 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)
Вот и всё. В простом кейсе этого достаточно для реализации задекларированных в начале статьи требований.
Обращаю ваше внимание на то, что JSON в object_data.attributes хранится как текст. Для экономии места, которого потребуется довольно много, лучше хранить его как BLOB и сжимать при сохранении.
Альтернативный простой вариант — воспользоваться плагином для Sqlite, который позволяет не только сжимать, но еще и шифровать данные. Правда, он платный.
Внимательный читатель наверняка обратил внимание на таблицу object_data_history. Она полностью дублирует object_data, добавляя лишь версию объекта, и позволяет сохранять историю изменений с нужной «глубиной». При желании, можно будет не только показать на экране данные без каких-то существенных доработок логики, но и легко «откатить» состояние объекта на нужную версию. Кроме того, история будет полезна для определения факта изменения объекта.
Делаем «хороший» поиск
За бортом у нас остался только поиск. И тут нам снова поможет SQLite, который предоставляет хоть и ограниченный, но, тем не, менее full text search (FTS). Старые добрые индексы, кстати, никуда не делись и так же будут полезны.
Тут есть один нюанс. В поставку SQLite Андроида расширение FTS не включено. Кроме того, в библиотеке есть и другие полезные расширения, которые вам могут понадобиться. Поэтому придется либо найти уже готовую сборку со всем необходимым, либо собрать самостоятельно, что совсем не сложно. Для этого читаем мануал и делаем все по шагам. Дальше останется только подключить полученный aar к своему проекту и заменить все ссылки с android.database.sqlite.SQLiteDatabase на ваш пакет org.sqlite.database.sqlite.SQLiteDatabase.
Но вернемся к FTS. Использование content-less таблиц (см. таблицу 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 возвращаем текущее значение и подписываемся на событие потери фокуса, чтоб запомнить, где и когда пользователь его менял.
В базовой реализации метод setInitialValue предназначен для сохранения исходного значения. rememberChangeLocation фиксирует дату и место изменения. А метод 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 и другие экстеншены)
Стоит иметь ввиду, что наличие требований на массовое изменение или выборку отдельных атрибутов объектов в данной модели доставит вам много неудобств и скажется на производительности. В таком случае, стоит серьезно подумать о том, подходит ли вам подобное решение.
Исходный код к статье с готовым примером реализации можно посмотреть на гитхабе.