
Всем привет, на связи Денис, ведущий мобильный разработчик в компании «СИГМА». Совместно с Александром, руководителем мобильной разработки, мы поделимся подробностями работы над проектом «Автоматизированная система управления мобильными бригадами» (АСУ МБ). Расскажем, как организовали работу с файлами, с какими трудностями столкнулись и какое решение получилось создать. Поехали!
Немного о файлах

Файл — это тонкая прозрачная полиэтиленовая папка-конверт для хранения бумаг… Нет, не то. Файл — это взаимосвязанный набор данных, имеющий имя и хранящийся в памяти как единое целое. Практически все мобильные приложения так или иначе работают с файлами: это могут быть фото, видео, текстовые данные, аудиофайл, электронная подпись и множество других форматов. Работа с файлами в зависимости от требований может включать в себя как простой показ данных из файловой системы, так и запутанные и сложные процессы, такие как привязка к жизненному циклу связанной с файлом сущности, показ метаданных файла на экране предварительного просмотра, отправка множества файлов в условиях плохого соединения и прочее, прочее…
Работа с файлами в контексте «толстого клиента»
Ранее Александр рассказывал про проект АСУ МБ и тернистый путь, который система прошла прежде, чем стать одним из передовых решений для автоматизации работы выездных бригад в энергетике. Напомню, что «Мобильные бригады» — это мобильное приложение для сотрудников, работающих «в полях»: электромонтеров, монтажников и других специалистов. С его помощью они получают различные задания на диагностику, обслуживание и ремонт объектов электросетевой инфраструктуры. Там же и отчитываются о результатах работ. Приложение может работать в офлайн режиме благодаря так называемому «толстому клиенту». Одной из его самых объёмных фич стала работа с файлами.

Для АСУ МБ файлы имеют ключевую роль во многих бизнес-процессах. Например, они позволяют:
передать в систему реальное состояние оборудования (фото, видео);
подтвердить личность подписавшего документ электронной подписью (.sig);
направить сам документ, который можно сформировать в виде печатной формы и отправить на сервер в формате pdf;
отправить экстренное сообщение в виде голосовой почты (аудиофайл).
Приложение постоянно обрастает полезными функциями, которые упрощают жизнь пользователям и ускоряют получение электричества в ваши дома. И практически всегда эти функции содержат работу с файлами.
Определение проблем
При первом запросе на функционал работы с файлами команда разработки обозначила для себя ряд вопросов, определивших объём работ. В общем виде вопросы были сформулированы так:
Как и куда сохранить файл?
Как унифицировать работу с разными типами файлов?
Как контролировать жизненный цикл файлов?
Каким образом организовать процесс загрузки файла?
Примеров работы с файлами в рамках мобильного приложения на просторах Интернета великое множество, но в нашем случае взять один из них и внедрить в проект не представлялось возможным. Готового оптимального решения, которое соответствовало бы заданным требованиям и учитывало специфику офлайн работы, не было. Поэтому мы реализовали собственное решение для работы с файлами, назвав его, внезапно, file-manager.

File-manager — компонент-библиотека, состоящая из множества утилит для работы с файлами. С помощью file-manager можно загружать, сохранять и удалять файлы с любыми разрешениями. Также силами библиотеки можно определять максимальные и минимальные размеры файлов, допустимые к сохранению расширения файла, изменять качество, ориентацию и размер фотографий, добавлять необходимую информацию прямо на фото. Каждый компонент создавался и редактировался по мере развития библиотеки и достоин отдельного описания, в рамках же данной статьи мы рассмотрим фундаментальную часть этой библиотеки, а именно FileRepository и его дочерние сущности. Какие именно? Сейчас разберём!
Как и куда сохранить файл
Для начала определимся, как мы будем сохранять файлы. Сам процесс сохранения файла несложный, однако из-за использования приложения в офлайн режиме необходимо грамотно структурировать хранение — как минимум, чтобы упростить поиск нужного файла. Сохранение мы решили определить во внешнее, скрытое от посторонних глаз хранилище файлов, так как использовать файл из галереи, который пользователь может спокойно удалить вручную до того, как произойдёт синхронизация и файл будет отправлен на сервер — не наш вариант. Также мы определили систему формирования каталогов. Она позволяет не только легко найти нужный файл, но и получить доступ к целому списку файлов, зная только название сущности, которой файлы принадлежат! Всю работу с внешним хранилищем мы определяем с помощью FileManager. Это интерфейс, использующий инструменты, предоставляемые java.io.File (далее — File) для сохранения, получения, копирования и удаления файлов и директорий, а также для проверки существования файла или директории. Интерфейс — это, конечно, хорошо, но давайте посмотрим его реализацию на примере метода получения файла:
class FileManagerImpl @Inject constructor(
private val pathFactory: PathFactory
) : FileManager {
override fun getFile(
objectType: EnumObjectType,
objectId: String,
fileName: String
): File {
val directory = File(pathFactory.getPath(objectType), objectId).getDirectory()
return File(directory, fileName).get()
}
Что такое EnumObjectType, рассмотрим позднее, сейчас нам интересны две фишки FileManager: PathFactory и пользовательские расширения класса File (см. на getDirectory() и get()). Начнём с PathFactory:
fun interface PathFactory {
fun getPath (objectType: EnumObjectType): String
}
PathFactory — функциональный интерфейс с единственным методом getPath, здесь настраивается весь путь к внешнему хранилищу, куда будет сохранён файл. Простая, но полезная и эффективная функция, дающая возможность настраивать путь к каталогу в зависимости от требований сборки или бизнес-процесса. Например, можно настроить путь к публичной директории для упрощения жизни разработчикам и тестировщикам в debug режиме:
class DebugPathFactory(
private val documentsDirectory: String = "${
Environment.getExternalStoragePublicDirectory(
Environment.DIRECTORY_DOCUMENTS
)
}${File.separator}",
private val downloadsDirectory: String = "${
Environment.getExternalStoragePublicDirectory(
Environment.DIRECTORY_DOWNLOADS
)
}${File.separator}"
) : PathFactory {
override fun getPath(objectType: EnumObjectType) {
/** Реализация для debug */
}
}
Что касается кастомных расширений, тут тоже всё довольно просто: пользовательская обработка стандартных методов File с выбросом пользовательских исключений, если нас что-то не устроило. Например, можем обработать факт существования файла или директории:
fun File.getOrCreateFile(): File = also {
if (!exists() && !createNewFile()) {
throw CreateFileException("Can`t create file $name")
}
}
fun File.getDirectory(): File = also {
if (!exists()) {
throw FileNotFoundException("Directory $name not found")
} else if (!isDirectory) {
throw GetDirectoryException("File $name not directory")
}
}
Используя пользовательские расширения File и настраивая нужным образом PathFactory, мы получили возможность взаимодействовать с файловой системой эффективным и предсказуемым образом с набором методов от FileManager. Едем дальше!
Универсальная модель и её сохранение
Как сохранить файл, определились, но как он взаимодействует с бизнес-процессом? Как мы поймём, какой файл привязан к наряд-допуску, а какой к дефекту, а если узнаем, то к какому именно? Как, в конце концов, понять, какой у файла тип? Можно, конечно, запихнуть всю эту информацию в название файла, а тип файла определять, вытаскивая его из расширения, а можно создать под это класс данных FileModel, содержащий всю необходимую информацию:
data class FileModel(
val id: String,
val fileName: String,
val ownerId: String,
val ownerType: EnumObjectType,
val fileType: EnumAttachmentType,
val createType: EnumCreateType,
val isConfirmed: Boolean = false,
val isSent: Boolean = false,
val data: Data
)
В этом классе мы определили наиболее важные для бизнес-процессов данные, что позволяет нам эффективно взаимодействовать со всеми компонентами репозитория файлов. Разберём каждый из них:
id — идентификатор модели;
fileName — имя файла (а иначе как его найти?);
ownerId — идентификатор владельца файла (для связи файла с сущностью, в рамках которой он был создан);
ownerType — enum класс всех возможных бизнес-сущностей приложения;
fileType — enum класс типа файла (фото дефекта, фото задания, подписанный документ и прочее);
createType — место создания файла (файл создан на устройстве или получен с сервера);
isConfirmed — этот флаг обозначает, что бизнес-процесс связанной сущности подошёл к концу и мы можем отправлять этот файл на сервер;
isSent — отправили ☺;
data — это sealed class с данными по файлу. Его рассмотрим чуть подробнее:
sealed class Data {
/** Файл повреждён */
data class Damaged(val message: String?) : Data()
/** Файл в представлении [File]. Используется для упрощения работы с медиафайлами */
data class FilePayload(val file: File) : Data()
/** Файл в представлении [ByteArray]. Используется для упрощения работы с электронной подписью */
data class BytesPayload(val bytes: ByteArray) : Data()
/** Файл в процессе загрузки */
object InProgress : Data()
}
Data хранит сам контент файла, связанного с FileModel. Сохраняя файл в виде data, мы получаем возможность единым образом обрабатывать любой тип файлов (привет, универсальный подход!).
FileModel будет храниться в базе данных. БД у file-manager своя и реализована с помощью библиотеки Room. Перенесём работу по взаимодействию с базой данных в отдельный интерфейс, назовём его DbFileCache. Отлично, теперь сохраним нашу модель. Сохранили, супер! А где же сам файл? Тот, что лежал в data?

Мы не добавили сохранение самого контента, того, что лежит в data, поэтому сохранение получилось неполным. Чтобы синхронизировать работу внешнего и внутреннего хранилища, воспользуемся транзакцией (зацементируем подход в названии, теперь это DbFileTransactionCache):
@Transaction
suspend fun put(fileEntity: FileEntity, fileSaveTransaction: suspend () -> Unit) {
insert(fileEntity)
fileSaveTransaction()
}
@Transaction
suspend fun delete(id: String, fileDeleteTransaction: suspend () -> Unit) {
delete(id)
fileDeleteTransaction()
}
Кстати да, для удаления делаем то же самое. Выполнив сохранение/удаление таким образом, мы исключаем ситуацию, когда мы сохранили файл, но не сохранили данные о нем (или наоборот, что, наверное, даже хуже).
Контроль жизненного цикла файла
Одним из ключевых был вопрос наблюдения за жизненным циклом файла, ведь ненужные более файлы лишним грузом висят в хранилище, увеличивая размер приложения. Для контроля готовности удаления файла есть два флага — isConfirmed и isSent. Если эти флаги true, значит, бизнес-процесс связанной с файлом сущности подошел к концу и сервером были приняты все связанные с сущностью данные, то есть её файлы готовы к удалению. В самом удалении никакой магии нет, у нас на это заведено два способа:
Удаление по ownerId — если старательный разработчик не забывает добавить удаление файлов при удалении сущности, то по ownerId можно легко это сделать, вытащив с помощью DbFileTransactionCache модели и выполнив удаление.
Если разработчик не добавил удаление (осуждаем) или файл привязан к сущности без жизненного цикла (тогда ладно), для этого есть специально обученный Worker, удаляющий вообще все файлы, у которых оба флага находятся в true.
Загрузка файлов и красивый интерфейс

Приложение хоть и рассчитано на офлайн работу, всё-таки нуждается в периодической синхронизации с системой (например, обновить данные по заданию или отправить новый дефект). За отправку файлов у нас отвечает RemoteFileManager, где реализованы сетевые запросы на получение и отправку данных, связанных с файлами. Хорошо, если всё это можно сделать в тепле и уюте, попивая чай в условиях стабильного интернета. Но как быть, если работаешь на местности с плохим соединением? Как ни крути, файлы — большие объёмы данных, нередко приходящие (и отправляющиеся) в больших количествах. Отправлять несколько файлов в теле одного запроса — плохой вариант, а подход загрузки файлов по одному содержит в себе опасность получения оператором неполной картины, например: не все файлы пришли, оператор из-за этого некорректно завершил задание, линия осталась с обрывом, люди грустят без света. Чтобы не грустить, был использован подход загрузки базовой информации по файлам, а затем сами файлы. Сделано это в AttachmentsHolder:
class AttachmentsHolder @Inject constructor() {
private val subject = MutableStateFlow<List<AttachmentModel>>(listOf())
subject — flow, хранящий в себе стоящие в очередь на загрузку файлы, представленные в виде AttachmentModel:
data class AttachmentModel(
val id: Int,
val ownerId: String,
val ownerType: EnumObjectType,
val fileName: String,
val fileType: EnumAttachmentType,
val state: AttachmentState,
val updatedAt: Date,
val size: Int
)
У AttachmentState два состояния: Created и Error:
sealed class AttachmentState {
object Created : AttachmentState()
data class Error(val message: String?) : AttachmentState()
}
Показать принцип работы AttachmentsHolder лучше всего получится на примере обновления данных (смотрим на комментарии):
private suspend inline fun updateTemplate(
objType: EnumObjectType,
objId: String,
replace: Boolean,
getAttachments: () -> List<AttachmentModel>
) {
// Получаем существующие для сущности fileName
val fileNames = dbCache
.getDbFileModels(objType, objId)
.map { it.fileName }
/*
Загружаем базовую информацию, фильтруем (проверка существования и нужно
ли заменить существующий файл пришедшим из сети)
*/
val attachments = getAttachments().filter { attach ->
replace || attach.fileName !in fileNames
}
// Грузим список базовой информации в subject
attachmentsHolder.put(attachments)
attachments.forEach {
updateFile(it)
}
}
private suspend fun updateFile(attachmentModel: AttachmentModel) =
with(attachmentModel) {
runCatching {
// Попытка получить сам файл с сервера
val file = remoteFileManager.getFile(attachmentModel)
val dbFileModel = attachmentModel.mapToDbFileModel()
// Удаляем базовую информацию из subject, она нам больше не нужна
attachmentsHolder.remove(id)
// Транзакционно сохраняем файл и информацию о нём
dbCache.put(dbFileModel) {
fileManager.saveFile(ownerType, ownerId, fileName, file)
}
}.onFailure {
// Если файл не удалось загрузить, меняем ему AttachmentState
attachmentsHolder.put(
attachmentModel.copy(
state = AttachmentState.Error(it.message)
)
)
}
}
Благодаря такому подходу мы можем сэкономить трафик, не гоняя впустую ненужные файлы, а сохранение в памяти базовой информации позволяет показать пользователю, что файл с таким fileName существует, надо только подождать (ну или то, что файл с таким именем загрузить не удалось). Сделаем это с помощью AttachmentState:

AttachmentState.Created присваивается AttachmentModel при попадании в subject, AttachmentState.Error он становится, если файл не загрузится. Очевидно, в первом случае необходимо показать индикатор загрузки, во втором — ошибку загрузки. Для пользователя это будет выглядеть примерно так:

Использование AttachmentHolder позволяет явно сообщить пользователю, в каком состоянии находится файл: успешно ли скачан на устройство (тогда мы отобразим его в пользовательском интерфейсе), в процессе ли загрузки (индикатор прогресса) или не завершилась ли загрузка ошибкой (отобразим сообщение об ошибке на пользовательском интерфейсе). Оператор корректно завершил задание, линия отремонтирована, свет снова попадает в наши дома!
FileRepository
Мы рассмотрели основные дочерние сущности FileRepository:

Объединив все компоненты в единый репозиторий, получаем мощный инструмент для работы с файлами. Что он умеет:
Сохранять, удалять и копировать файл вне зависимости от типа создания (из сети или с мобильного устройства) и типа файла;
Получить файлы в виде «файл + информационная модель» согласно запросу: по идентификатору, типу владельца, только с флагом «Отправлено» — в общем, возможность комбинировать условия запроса файлов ограничивается лишь воображением (но лучше ограничивать бизнес-процессами);
Подтверждение файлов — изменение флага isConfirmed и isSent.
FileRepository;Подписка на файлы — динамическая работа с файлами, учитывает не только файлы в системе, но и загружаемые файлы из AttachmentsHolder:
@OptIn(ExperimentalCoroutinesApi::class)
override fun getFilesFlow(
objType: EnumObjectType,
objId: String,
isSent: Boolean?
): Flow<List<FileModel>> =
dbCache.getDbFileModelsFlow(objType, objId, isSent)
.flatMapLatest { dbList -> getFileModelsFlow(dbList) }
.flatMapLatest { fileModels ->
attachmentsHolder.getAttachmentsFlow(objType, objId)
.distinctUntilChanged()
.map { list -> list.map { it.mapToFileModel() } }
.map { attachmentMocks ->
attachmentMocks.filter { mock ->
fileModels.none { it.fileName == mock.fileName }
}
}
.map { filteredAttachmentMocks ->
fileModels.toMutableList().apply { addAll(filteredAttachmentMocks) }
}
Тут стоит сказать, что изначально идея создать для работы с файлами отдельный модуль, планирование его архитектуры и первая реализация с использованием RxJava принадлежит Александру, и дальнейшее развитие модуля, добавление полезных утилит, глобальные обновления (одним из таких обновлений является переход на Kotlin coroutines) – совместный труд нашей команды разработки, за что им большое спасибо!
Что-то ещё?
В первоначальном виде FileRepository успешно справлялся с поставленными задачами, но по мере получения дополнительных требований file-manager развивался и обрастал дополнительными компонентами, о которых хотелось бы вкратце рассказать.
FileConfigTool — компонент, конфигурирующий настройки для работы с библиотекой. Необходимость этих настроек появилась из-за возрастания времени на синхронизацию файлов — улучшение качества фотографий сопровождалось увеличением их размера, а разрастание бизнес-процессов увеличивало количество файлов, требующих синхронизации. Если для разных типов или бизнес-процессов необходимо использовать разные настройки, меняем их с помощью MutableStorageConfig.
RemoteOptionsTool — компонент, позволяющий регулировать отправляемую с файлом информацию на сервер. Очень полезная утилита в случаях, когда приложение работает с различными типами файлов и для каждого типа серверу необходим свой набор данных.
AttachInfoTool — мощная утилита для изображений, позволяющая добавить нужную информацию в саму фотографию. Скажу так, добавление этой утилиты сильно упростило работу пользователям!
CompressFileTool — что делать, если файл не подходит под настройку максимального размера, но сохранить его нужно? Ответ очевиден:

Изменить качество изображения и (или) его размеры! За сжатие и другие изменения состояния файла отвечает компонент CompressFileTool.
Выводы и планы на будущее
File-manager имеет обширный набор функций, описывающий взаимодействие приложения и файловой системы, включающей в себя три источника данных. Он не привязывается к сущностям отдельного проекта, его можно и нужно переиспользовать в других решениях, что мы с успехом и делаем.
На сегодня моей основной задачей является реализация file-manager в виде отдельной мультиплатформенной библиотеки, для возможности использовать библиотеку на текущих Kotlin Multiplatform проектах. Использовать классы типа File нельзя из-за их привязки к ОС Android, поэтому я использую принципиально другой подход…

Спасибо за внимание!