Что такое структура доступа к хранилищу

Обновлено: 04.07.2024

Fuck Storage Access Framework (FSAF)

Если вам когда-либо приходилось иметь дело с Storage Access Framework, вы должны понимать, как тяжело вам приходится из-за его API и отсутствия хороших примеров.

Эта крошечная библиотека пытается скрыть этот API, предоставляя вместо этого хорошо известный API, похожий на файл Java, абстрагируя как файлы SAF (или DocumentFiles), так и стандартные файлы Java. По сути, с этой библиотекой вам даже не нужно думать о том, какой файл использовать, потому что все это делается внутри.

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

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

Существует три основных сценария работы с файлами:

  • Чтение или запись в предоставленный пользователем файл.
  • Создайте новый файл в указанном пользователем каталоге с указанным пользователем именем.
  • Использовать предоставленный пользователем каталог в качестве дампа файлов на протяжении всего жизненного цикла приложения.

Первые два обычно довольно просто реализовать даже с помощью обычного SAF API, поскольку обычно это однократная операция. А вот третья не такая уж тривиальная. Давайте посмотрим, как эта библиотека поможет вам справиться с этими тремя сценариями при использовании SAF.

Чтение или запись в предоставленный пользователем файл

Это довольно просто, просто используйте метод FileChooser.openChooseFileDialog(), который вернет вам Uri выбранного файла:

Если пользователь ничего не выберет и нажмет, будет вызван метод onCancel().

Создайте новый файл в указанном пользователем каталоге с указанным пользователем именем.

То же самое касается сценария, когда вы хотите создать файл в каталоге, выбранном пользователем, используйте метод FileChooser.openCreateFileDialog():

Имейте в виду, что при создании файла с именем уже существующего файла в каталоге SAF добавит "(1)" в конец нового файла.

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

Здесь начинается самое интересное. Прежде всего, вам нужен каталог, который вы затем будете использовать для хранения некоторых файлов (загруженных изображений/видео и т.д.). Этот каталог должен иметь соответствующие разрешения на чтение/запись, а также разрешение на сохранение. Без разрешения на сохранение вы не сможете получить доступ к каталогу после перезагрузки телефона. FSAF автоматически добавляет все необходимые флаги, требующие разрешения на чтение/запись и сохранения при выборе каталога (или файла) через FileChooser.

После получения Uri каталога вам, вероятно, потребуется где-то сохранить его, чтобы не потерять. Затем вам нужно зарегистрировать BaseDirectory . BaseDirectory — это корневой каталог, внутри которого вы сможете создавать новые файлы/каталоги/подкаталоги и т. д. Вы можете зарегистрировать собственную реализацию BaseDirectory, наследуя от класса BaseDirectory и переопределяя необходимые методы:

Затем вам нужно создать экземпляр и зарегистрировать его в FileManager:

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

BaseDirectory требует переопределения трех методов:

  • getDirUri() вы должны вернуть Uri в базовый каталог, который был возвращен вам в обратном вызове onResult после вызова FileChooser.openChooseDirectoryDialog() . Это Uri вашего базового каталога где-то внутри SAF. Он может быть на SD-карте или где-то во внешней памяти телефона. Он всегда должен возвращать ненулевое значение.
  • getDirFile() — это альтернативный каталог с поддержкой файлов Java. Дело в том, что обычно невозможно заставить пользователей немедленно переключаться с одного на другое. Таким образом, этот процесс может занять некоторое время, и чтобы упростить его для пользователей, вы можете добавить возможность выбора каталога, поддерживаемого файлом Java, или каталога, поддерживаемого SAF. И чтобы выяснить, какой базовый каталог используется в данный момент, вам нужно изменить возвращаемое значение третьего переопределенного метода.
  • currentActiveBaseDirType() вы должны вернуть либо ActiveBaseDirType.SafBaseDir, либо ActiveBaseDirType.JavaFileBaseDir, в зависимости от того, что выбрал пользователь. Этот метод вызывается каждый раз, когда вы хотите создать новый файл или каталог, и он нужен, чтобы выяснить, где именно он должен быть создан. Если вы не используете альтернативный каталог файлов Java, вы можете всегда возвращать здесь ActiveBaseDirType.SafBaseDir.Но вы не должны использовать его только для каталогов, поддерживаемых Java File. Java File API довольно быстрый и простой.

Вот как может выглядеть базовый каталог при использовании обоих методов:

Где ChanSettings.saveLocation.fileApiBaseDir и ChanSettings.saveLocation.safBaseDir являются просто оболочками над общими настройками.

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

Создание нового файла или каталога

Это довольно просто (особенно при использовании Kotlin):

При этом будет создан экземпляр нового класса AbstractFile, но он еще НЕ БУДЕТ создавать ничего на диске. Думайте об этом как об обычном файле Java, где для физического создания файла на диске вам нужно сначала вызвать метод createNew() / mkdir(). AbstractFile — это класс без сегментов и корнем, указывающим на базовый каталог. AbstractFile - это просто абстракция как над файлом/каталогом, поддерживаемым SAF, так и над файлом/каталогом, поддерживаемым файлом Java. Сегмент может быть либо сегментом каталога (в данном случае это имя каталога), либо сегментом файла (в данном случае это имя файла). Сегменты каталога НЕ ДОЛЖНЫ содержать расширения (например, «.txt»). Сегменты файлов могут содержать или не содержать сегменты файлов. Это довольно просто. Если вы хотите создать каталог, используйте DirectorySegment, если файл использует FileSegment. Но есть одно правило: после создания FileSegment вы больше не можете ничего создавать с этим путем, иначе будет выдано исключение. Точно так же, как при использовании Java File API.

Теперь давайте создадим пару каталогов и файлов:

Это создаст файлы file1.txt и dir1 внутри базового каталога. Затем он создаст file2.txt внутри dir1 и после этого file3.txt внутри dir2 внутри dir1, так что это будет выглядеть так:

И все. Есть пара других перегруженных версий метода create() (и даже createUnsafe(), если вы знаете, что делаете), вы можете найти их все в классе FileManager.

Проверка существования базового каталога

Пользователь может удалить ваш базовый каталог в любое время! Поэтому вы должны проверить, существует ли он, прежде чем что-либо делать. Обычно вы хотите сделать это перед вызовом FileManager.newBaseDirectoryFile() . Чтобы проверить, существует ли базовый каталог, используйте метод FileMananger.baseDirectoryExists().

Забыть и отменить регистрацию базового каталога

Пользователь может захотеть изменить базовый каталог в любое время, и вам нужно с этим справиться. Перед регистрацией нового базового каталога, если старый базовый каталог все еще существует, вы можете захотеть вернуть все права доступа к каталогу (ну, на самом деле никто не заставляет вас этого не делать, но это хорошая практика). Используйте метод FileChooser.forgetSAFTree(), чтобы отозвать любые разрешения, которые у вас есть для этого каталога. После этого вы больше не сможете получить доступ к этому каталогу. Поэтому вы можете спросить пользователя, хотят ли они скопировать файлы из старого базового каталога в новый. К счастью, у FileManager есть API для этого ( FileManager.copyDirectoryWithContent() ). Вы даже можете добавить возможность удалять файлы в старом базовом каталоге после их копирования в новый. Для этого также есть API ( FileManager.deleteContent() ). Вероятно, вам НЕ СЛЕДУЕТ УДАЛЯТЬ сам базовый каталог, поскольку он выбирается пользователем.

После копирования файлов и удаления старых файлов вы также можете удалить базовый каталог из FileManager с помощью FileManager.unregisterBaseDir() .

Чтение/запись в файл

Для этого можно использовать метод FileManager.withFileDescriptor(). Он принимает AbstractFile (который должен быть файлом, а не каталогом!) FileDescriptorMode, который описывает, что вы хотите сделать с файлом (чтение/запись/запись усечения (поскольку по умолчанию SAF не будет усекать старое содержимое файла)) и lambda, в которую будет передан FileDescriptor.

В качестве альтернативы вы можете использовать FileManager.getInputStream() или FileManager.getOutputStream() .

SAF работает медленно. Каждая операция ввода-вывода файла SAF занимает около 20-30 мс, поскольку она использует вызов IPC. И иногда вы можете захотеть проверить, много ли файлов существует на диске, и если их нет, то создать их (или что-то подобное, требующее много файловых операций). Это настолько медленно, что даже в примере с Google они используют хаки, чтобы сделать его быстрее. Ну, эта библиотека также использует хаки, чтобы сделать ее еще быстрее. По сути, если вам нужно выполнить много операций с файлами, самый быстрый способ сделать это — прочитать весь каталог (с файлами/подкаталогами и всеми метаданными файлов, такими как имена файлов/размеры файлов и т. д.) за один раз (в огромном пакет) в структуру InMemory-Tree и выполнить все необходимые операции с этим деревом. Для этого и нужны снимки.

Чтобы создать снимок каталога, используйте метод FileManager.createSnapshot(). Если вы хотите включить в снимок подкаталоги, используйте параметр includeSubDirs. После создания снимка вы можете делать с ним все, что захотите.но после того, как вы закончите с этим, НЕ ЗАБУДЬТЕ ВЫПУСТИТЬ ЕГО с помощью FileManager.releaseSnapshot() . Вам нужно указать тот же AbstractFile в качестве параметра, который ДОЛЖЕН БЫТЬ каталогом. В качестве альтернативы вы можете использовать FileManager.withSnapshot(), который автоматически создаст снимок для вас.

^ +-- Уже не так. Вам больше не нужно выпускать моментальный снимок, так как каждый снимок теперь является отдельным автономным объектом, поэтому его можно просто безопасно GCed.

О нас

Fuck Storage Access Framework (или просто FSAF) — это удобная библиотека, которая скрывает от вас все раздражающие части Storage Access Framework (такие как DocumentTrees/DocumentIds/DocumentFiles/DocumentContracts и прочую ерунду), оставляя только похожий API к старому-доброму Java File API

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

Вместо этого Google хотел бы, чтобы мы работали с контентом в более общем плане, независимо от того, поступает ли этот контент из файлов, из таких сервисов, как Google Диск, или из других мест. С этой целью Android поставляется с Storage Access Framework для создания и использования контента.

Структура доступа к хранилищу

Давайте на минутку подумаем о фотографиях.

Человек может управлять фотографиями как:

  • фотографии на устройстве, созданные приложением, например галереей.
  • фотографии, хранящиеся в Интернете в специальном фотосервисе, таком как Instagram
  • фотографии, хранящиеся в Интернете в стандартном хранилище файлов, таком как Google Диск или Dropbox.

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

Каким-то образом этот клиент электронной почты или текстовых сообщений должен позволять пользователю выбирать фотографию. Некоторые разработчики пытаются сделать это, ища файлы фотографий в файловой системе, но при этом многие фотографии будут пропущены, особенно в более новых версиях Android. Некоторые разработчики используют класс MediaStore для запроса доступных фотографий. Это разумный выбор, но MediaStore позволяет запрашивать только определенные типы контента, например фотографии. Если пользователь хочет прикрепить PDF-файл к электронному письму, MediaStore — не лучшее решение. Кроме того, MediaStore знает только о локальном контенте, а не об элементах в облачных сервисах, используемых пользователем.

Среда доступа к хранилищу предназначена для решения этих проблем. Он предоставляет собственный пользовательский интерфейс «сборщика», позволяющий пользователям находить интересующий файл, соответствующий типу MIME, который требуется клиентскому приложению. Поставщики документов просто публикуют сведения об имеющемся у них содержимом, включая элементы, которых может не быть на устройстве, но которые можно получить при необходимости. Пользовательский интерфейс средства выбора позволяет легко просматривать и выполнять поиск среди всех возможных поставщиков документов, чтобы упростить процесс для пользователя. И, поскольку средство выбора предоставляет Android, средство выбора должно более надежно предоставлять результат клиентскому приложению на основе выбора пользователя (если таковой имеется).

Больше игральных костей!

Модули Diceware проектов Java и Kotlin основаны на образце DiceLight, который мы видели ранее. Разница в том, что в этой версии приложения есть дополнительное меню с «Открыть файл Word», где пользователь может выбрать другой список слов для использования в качестве источника слов для фраз-паролей. Чтобы позволить пользователю выбирать список слов, мы будем использовать Storage Access Framework.

Действия SAF

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

Эти три элемента пользовательского интерфейса связаны с тремя действиями Intent:

Действие Эквивалентная роль
ACTION_OPEN_DOCUMENT файл открыт
ACTION_CREATE_DOCUMENT сохранить файл как
ACTION_OPEN_DOCUMENT_TREE выберите каталог
< /p>

Эти три действия Intent предназначены для использования с startActivityForResult() . В onActivityResult(), если мы получили результат, он будет содержать Uri, указывающий на документ (для ACTION_OPEN_DOCUMENT и ACTION_CREATE_DOCUMENT) или дерево документов (для ACTION_CREATE_DOCUMENT). Затем мы можем использовать этот Uri для записи содержимого, чтения существующего содержимого или поиска дочерних документов в дереве.

В настоящее время Google рекомендует использовать registerForActivityResult() с подходящим ActivityResultsContract и ActivityResultsCallback вместо прямого использования startActivityForResult().К счастью, Jetpack предоставляет три контракта, по одному на каждое действие:

< /th> < td style="text-align:center;">ActivityResultContracts.OpenDocumentTree
Контракт Действие
ActivityResultContracts.OpenDocument ACTION_OPEN_DOCUMENT
ActivityResultContracts.CreateDocument ACTION_CREATE_DOCUMENT
ACTION_OPEN_DOCUMENT_TREE
< /p>

Открытие документа

Технически мы не «открываем» документ с помощью ActivityResultContracts.OpenDocument . Вместо этого мы запрашиваем Uri, указывающий на какой-то документ, который выбирает пользователь. Итак, наши образцы Diceware настраивают объект openDoc для представления этого контракта и запроса:

Затем, когда пользователь нажимает на элемент панели приложения "Открыть", мы вызываем метод launch() для openDoc, чтобы инициировать запрос на открытие документа:

На этот раз, однако, контракт требует некоторых входных данных: массива строк, где каждая строка представляет либо конкретный тип MIME (например, text/plain ), либо тип MIME с подстановочными знаками (например, text/* ). В нашем случае мы передаем один текстовый/обычный тип MIME, ограничивая пользователей выбором файла такого типа.

Пользователю предоставляется пользовательский интерфейс ACTION_OPEN_DOCUMENT системы для просмотра различных мест и выбора документа:

Storage Access Framework UI, показ документов

Пользовательский интерфейс Storage Access Framework, показ документов

Наши обратные вызовы получат Uri, указывающий на выбранный документ. Мы передаем это в MainMotor и просим его сгенерировать для нас кодовую фразу, используя этот Uri и текущее запрошенное пользователем количество слов.

Если вы хотите попробовать это сами, вы можете загрузить файлы списка слов diceware.wordlist.txt или eff_large_wordlist.txt, поместить их на тестовое устройство, а затем открыть в приложении Diceware.

Использование выбранного контента

MainMotor во многом такой же, как и раньше. Однако, когда мы генерируем кодовую фразу, мы передаем Uri для generate() в PassphraseRepository:

Однако наша исходная фраза-пароль начинается с Uri, указывающего на наш ресурс:

В PassphraseRepository наш кеш слов теперь представляет собой LruCache. Это класс Android SDK, представляющий поточно-ориентированную карту, размер которой ограничен определенным количеством записей. Если мы попытаемся поместить в кеш больше вещей, чем у нас есть место, LruCache удалит наименее недавно использовавшуюся запись («LRU»). В нашем случае кеш имеет ключ Uri и ограничен не более чем четырьмя списками слов:

Если у нас еще нет слов, нам нужно их загрузить. В DiceLight мы всегда открывали актив. Теперь мы видим, является ли актив волшебным ASSET_URI, и мы используем AssetManager и его функцию open() только в этом случае. В противном случае мы используем ContentResolver и openInputStream(), чтобы получить содержимое, идентифицированное Uri, которое мы получили от ACTION_OPEN_DOCUMENT. Вы можете получить ContentResolver, вызвав getContentResolver() для Context , например, предоставленного конструктору репозитория (Java) или функции generate() (Kotlin).

Остальная часть PassphraseRepository такая же, так как остальная часть кода для загрузки слов не имеет значения, получен ли InputStream из актива или получен из ContentResolver .

DocumentFile и остальная часть CRUD

ACTION_OPEN_DOCUMENT (и ActivityResultContracts.OpenDocument) предоставит вам Uri для документа, который вы можете открыть для чтения — «R» в «CRUD», как мы видели в Diceware. Storage Access Framework также поддерживает остальные операции: создание, обновление и удаление.

Чтобы помочь вам в этих операциях, Jetpack предлагает класс DocumentFile, который предоставляет удобные функции для получения ключевых сведений о полученном Uri. Для ACTION_OPEN_DOCUMENT и ACTION_CREATE_DOCUMENT вы можете получить DocumentFile для Uri, вызвав DocumentFile.fromSingleUri() и передав этот Uri. Затем DocumentFile имеет такие функции, как getType(), чтобы сообщить вам тип MIME, связанный с этим конкретным фрагментом содержимого.

Создать

ACTION_CREATE_DOCUMENT предоставит вам Uri для документа, который вы можете открыть для записи, так как это ваш документ. В настоящее время для этого можно использовать ActivityResultContracts.CreateDocument().

ACTION_CREATE_DOCUMENT поддерживает дополнительное имя EXTRA_TITLE , содержащее желаемое имя файла или другое «отображаемое имя» — это не обязательно должно быть классическое имя файла с расширением. Функция launch() для ActivityResultContracts.CreateDocument() принимает строку, которая выполняет ту же роль.

Обратите внимание, однако, что пользователь имеет право заменить предложенный вами заголовок на что-то другое.Вы можете узнать название Uri, вызвав getName() для DocumentFile. Опять же, имейте в виду, что это не обязательно должна быть классическая структура имени файла. В частности, расширение файла не требуется.

Обновить

URI, возвращенный из запроса ACTION_OPEN_DOCUMENT, может быть доступен для записи; Uri из ACTION_CREATE_DOCUMENT должен быть доступен для записи. Вы можете узнать это, вызвав canWrite() для DocumentFile для Uri. Если это возвращает true , вы можете использовать openOutputStream() в ContentResolver для записи в этот документ.

Удалить

Если вы можете написать контент, вы также можете его удалить. Для этого вызовите delete() для DocumentFile для этого Uri.

Получить постоянный доступ

По умолчанию у вас будут права на чтение (и, при необходимости, на запись) документа, представленного Uri, до тех пор, пока действие, запрашивающее документ через ACTION_OPEN_DOCUMENT или ACTION_CREATE_DOCUMENT, не будет уничтожено.

Если вы передаете Uri другому компоненту, например другому действию, вам потребуется добавить FLAG_GRANT_READ_URI_PERMISSION и/или FLAG_GRANT_WRITE_URI_PERMISSION к намерению, используемому для запуска этого компонента. Это расширяет ваш доступ до тех пор, пока этот компонент не будет уничтожен. Обратите внимание, что все фрагменты считаются частью действия, создавшего их, поэтому вам не нужно беспокоиться о расширении прав с одного фрагмента на другой.

Однако, если вам нужны права на перезапуск приложения, вы можете вызвать takePersistableUriPermission() для ContentResolver , указав Uri документа и разрешения ( FLAG_GRANT_READ_URI_PERMISSION и/или FLAG_GRANT_WRITE_URI_PERMISSION ), которые вы хотите сохранить. Затем вы можете сохранить Uri где-нибудь — например, в SharedPreferences, который мы рассмотрим в следующей главе. Позже, когда ваше приложение снова запустится, вы сможете получить Uri и, вероятно, по-прежнему использовать его с ContentResolver и DocumentFile даже для совершенно новой активности или совершенно нового процесса. Эти права сохраняются даже после перезагрузки.

Однако эти права не сохранятся после удаления или перемещения документа каким-либо другим приложением. Вы можете вызвать exists() для DocumentFile, чтобы узнать, указывает ли ваш Uri на существующий документ.

Кроме того, вы можете вызвать getPersistedUriPermissions(), чтобы узнать, какие постоянные разрешения есть у вашего приложения. Это возвращает список объектов UriPermission, где каждый из них представляет Uri , какие постоянные разрешения (чтение или запись) у вас есть и когда истекает срок действия разрешений.

Дерева документов

ACTION_OPEN_DOCUMENT и ACTION_CREATE_DOCUMENT достаточно для большинства приложений.

Однако могут быть случаи, когда вам понадобится аналог диалогового окна "выбрать каталог", чтобы пользователь мог выбрать место, где вы можете создать несколько документов (или работать с ними). Например, предположим, что ваше приложение предлагает генератор отчетов, который берет данные из базы данных и создает отчет с таблицами, графиками и прочим. Некоторые форматы файлов, например PDF, могут содержать весь отчет в одном файле — для этого используйте ACTION_CREATE_DOCUMENT, чтобы пользователь мог выбрать, куда поместить этот отчет. Для файлов других форматов, таких как HTML, может потребоваться несколько файлов (например, основная часть отчета в формате HTML и встроенные графики в формате PNG). Для этого вам действительно нужен «каталог», в котором вы можете создавать все эти отдельные фрагменты контента.

Для этого Storage Access Framework предлагает деревья документов.

Получение дерева

Вместо ACTION_OPEN_DOCUMENT можно использовать ACTION_OPEN_DOCUMENT_TREE… или, в настоящее время, ActivityResultContracts.OpenDocumentTree. Вы получите Uri, представляющий дерево. У вас должен быть полный доступ для чтения/записи не только к этому дереву, но и ко всему, что находится внутри него.

Работа в дереве

Самый простой подход к работе с деревом — использование вышеупомянутой оболочки DocumentFile. Вы можете создать один, представляющий дерево, используя статический метод fromTreeUri(), передав Uri, который вы получили из запроса ACTION_OPEN_DOCUMENT_TREE.

Оттуда вы можете:

  • Вызовите listFiles(), чтобы получить непосредственные дочерние элементы корня этого дерева, возвращая массив объектов DocumentFile, представляющих эти дочерние элементы.
  • Вызовите isDirectory(), чтобы убедиться, что у вас действительно есть дерево (или вызовите его для дочернего элемента, чтобы узнать, представляет ли этот дочерний элемент поддерево)
  • Для тех существующих дочерних элементов, которые являются файлами ( isFile() возвращает true ), используйте getUri(), чтобы получить Uri для этого дочернего элемента, чтобы вы могли прочитать его содержимое с помощью ContentResolver и openInputStream()
  • Вызовите createDirectory() или createFile(), чтобы добавить новый контент в качестве непосредственного дочернего элемента этого дерева, получив в результате DocumentFile
  • Для сценария createFile() вызовите getUri() для DocumentFile, чтобы получить Uri, который можно использовать для записи содержимого с помощью ContentResolver и openOutputStream()
  • и так далее

Обратите внимание, что вы можете вызвать метод takePersistableUriPermission() для ContentResolver, чтобы попытаться получить постоянный доступ к дереву документа, точно так же, как вы можете это сделать для Uri для отдельного документа.

В прошлую пятницу разработчики XDA опубликовали статью под названием «Среда доступа к хранилищу — единственный способ для приложений работать со всеми вашими файлами в Android Q. И это ужасно». Как и следовало ожидать из этого названия, автор выступает против Storage Access Framework. Комментаторы в целом тоже.

Я согласен с тем, что API Storage Access Framework имеет проблемы, и согласен с некоторыми аргументами, изложенными в этой статье. Однако другие аргументы, по-видимому, отражают тот факт, что автор разрабатывает файловый менеджер и, следовательно, придерживается определенного взгляда на разработку приложений для Android, который не отражает разработку приложений в целом.

Итак, позвольте мне предложить несколько контраргументов.

В Q Google вводит (и требует) «Scoped Storage», благодаря которому Android работает как iPhone, где хранилище изолировано для каждого приложения. Приложение может получить доступ только к своим собственным файлам, и если оно будет удалено, все его файлы будут удалены.

Обратите внимание на сравнение с iPhone. Мы еще вернемся к этому.

SAF доступен начиная с Android 5.0 Lollipop, но разработчики, как правило, не используют его без необходимости, поскольку у него сложный и плохо документированный API, плохой пользовательский интерфейс, низкая производительность и низкая надежность (в основном в форма проблем с реализацией, зависящих от производителя устройства).

API для получения Uri из Storage Access Framework довольно прост. API для использования Uri разрозненный, с мешаниной из ContentResolver и DocumentFile, если вам нужно что-то разумное. И документация ограничена, но это довольно распространено. Но как только вы найдете правильные методы для этих двух классов, API не станет особенно сложным и будет напоминать традиционный файловый ввод-вывод.

(Эй, Йигит, если ты это читаешь: как насчет новой привлекательной библиотеки StorageX в компонентах архитектуры? 😃)

Конечно, в SAF API есть пробелы, например:

Не существует аналога ACTION_CREATE_DOCUMENT_TREE для ACTION_CREATE_DOCUMENT и ACTION_OPEN_DOCUMENT_TREE , что заставляет некоторых разработчиков пытаться использовать взломы, которые оказываются ненадежными

Нельзя указать, что нам нужен документ для чтения и записи в ACTION_OPEN_DOCUMENT , что приведет к тому, что пользователи будут обмануты поставщиком аудиодокументов Google

Производительность будет хуже, чем с API файловой системы, но будет ли это иметь значение для приложения — подробнее об этом позже.

В идеале Google должен был бы делать больше с точки зрения тестирования, например:

Написание набора тестов на соответствие, чтобы можно было легко протестировать реализации DocumentsProvider, чтобы определить, соответствуют ли они ожиданиям; и

Применение этого набора тестов на соответствие к любым предустановленным реализациям DocumentsProvider в составе набора тестов на совместимость

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

Немногим приложениям требуется доступ ко всему содержимому произвольного каталога на внешнем или съемном носителе. Многим приложениям вообще не нужен доступ к внешнему или съемному хранилищу. Многие из тех, кто это делает, могут обойтись просто getExternalFilesDir() . И даже тем, кому нужен доступ к произвольному контенту, часто нужен всего один файл. Для этого полученный пользовательский интерфейс не сильно отличается от диалогов «открыть файл» и «сохранить файл как», которые люди использовали в настольных операционных системах на протяжении десятилетий. Даже диалоговое окно, которое автор осуждает здесь, совпадает с диалоговым окном «выбрать каталог», с которым время от времени сталкиваются пользователи настольных компьютеров.

Производительность файлового ввода-вывода несколько снижается в SAF, но самая серьезная проблема заключается в операциях с файловыми каталогами, где она примерно в 25-50 раз медленнее, чем обычный доступ к файлам, возможный в Pie.

Я не проводил тесты, но этот результат меня не шокирует. Существует два раунда IPC для каждого вызова SAF API (ваше приложение к SAF, затем SAF к DocumentsProvider ). Это добавит накладные расходы по сравнению с тем же самым с файловой системой.

Однако для этого потребуется несколько приложений. Файловые менеджеры работают, поскольку они работают не только с произвольными каталогами во внешнем/съемном хранилище, но и с произвольными каталогами с произвольным содержимым. Немногие приложения являются файловыми менеджерами или имеют характеристики доступа к хранилищу файлового менеджера. Кроме того, это то, что SAF может со временем улучшить, например, за счет более агрессивного кэширования.

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

Я согласен. Я много лет умолял авторов библиотек поддерживать потоки.

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

Настоящая проблема возникает из-за библиотек, которым нужен произвольный доступ к содержимому файлов. Они в основном устойчивы к SAF и требуют копирования данных. Однако некоторые библиотеки принимают File в качестве параметра, затем разворачиваются, открывают FileInputStream и работают с ним. Такие библиотеки должны иметь возможность принимать InputStream в качестве альтернативы File, и если они этого не сделают, это может быть исправлено. Для библиотек с открытым исходным кодом может приветствоваться добавление кода.

Конечно, многие из этих библиотек уже были бы адаптированы, если бы больше разработчиков попросили их адаптироваться, а не использовать обходные пути script-kiddie, чтобы попытаться избежать правильного использования Uri. Просто говорю.

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

Напоминаем, что автор сравнил объем хранилища с iPhone. Что ж, в глазах экспертов по конфиденциальности и безопасности, за которыми я слежу, iPhone — это золотой стандарт, а Android — мусорный огонь. Люди проводят сбор средств, чтобы передать iPhone людям из групп риска, которые в противном случае могли бы позволить себе только устройство Android. Я надеюсь, что эксперты по конфиденциальности и безопасности оценят стремление Android стать более похожей на iPhone в этой области.

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

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

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

Здесь можно еще многое сделать для улучшения конфиденциальности и безопасности. Например, Android нужен экран настроек, где пользователи могут просматривать, какие разрешения на доступ к долговременному хранилищу не выданы, и иметь возможность отозвать их. Пользователи должны иметь возможность контролировать, является ли доступ постоянным или нет, в момент выбора каталога (или даже как долго должно быть предоставленное разрешение, со значениями между «только сеанс» и «навсегда»). Диалоговое окно с предупреждением может быть оправдано, если пользователь предоставляет постоянный доступ к корневому каталогу, чтобы убедиться, что пользователь понимает последствия такого выбора.

В идеале, в долгосрочной перспективе, большинство пользователей будут получать диалоговое окно "дай мне доступ ко всему навсегда" один или два раза в год. Будем надеяться, что все остальное будет решаться с более узкими рамками с точки зрения охвата (например, отдельные файлы) или времени (например, доступ на меньшее время, чем «навсегда»).

Однако некоторых опытных пользователей это раздражает. Я не собираюсь этого отрицать.

Единственное «улучшение безопасности» связано с тем, что теперь это становится более сложным процессом для пользователя.

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

Официальная причина, заявленная в документации по бета-версии Android Q, заключается в том, чтобы «дать пользователям больший контроль над своими файлами и ограничить их беспорядок».

Эта часть документации нуждается в исправлении. Изменения в Q Beta 3 практически устранили это обоснование.

Если Google действительно заинтересован в том, чтобы дать пользователям больше контроля над файлами и беспорядком, им следует разработать решение, которое напрямую решает эту проблему, а не ложно называет текущий дизайн Android Q таким улучшением.

< /цитата>

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

В DaysCounter я использовал разрешения на хранение в функции резервного копирования данных. Экспорт данных работал следующим образом:

  1. если пользователь хотел экспортировать данные локально, файл создавался в определенном месте назначения с использованием методов ввода-вывода по умолчанию.
  2. если пользователь хотел экспортировать данные на Google Диск, он должен был войти в систему с помощью кнопки Google. Позже файл был создан в корневой папке и отправлен непосредственно в корневую папку Google Диска. Затем временный файл был удален.

Импорт данных работает очень похоже: выбор определенного файла с устройства или просто получение последнего файла с заданным префиксом из корневой папки Google Диска.

Несмотря на то, что это работало, из-за изменений разрешений в Android 10 и 11 мне пришлось изменить его, поэтому я проверял возможные способы решения этой проблемы. Прежде всего, я не хотел бороться с Android и хотел сделать эту функцию снова великолепной и максимально плавной. Я выбрал Storage Access Framework.

Что такое Storage Access Framework

Как работает Storage Access Framework

Пример с фрагментами кода

Во-первых, давайте начнем с точек входа в SAF, которые являются намерениями. Очень просто. Два намерения, первое для открытия файла, второе для сохранения файла. Я также указал тип файлов и начальное имя файла при сохранении нового.

Если мы хотим использовать его в Activity:

Наконец, пришло время для последней части, которая использует URI для создания правильного потока (здесь я использую Single из RxJava2, но его можно заменить на Coroutines или что-то еще). Я использую здесь функцию расширения из Kotlin Closeable.kt, которая заботится о закрытии ресурса, когда он не нужен.

Мой вариант использования – экспорт/импорт данных в каком-либо построчном формате, например CSV.

Пора подводить итоги

Короче говоря, Storage Access Framework позволил мне добиться того же результата, что и раньше (локальный импорт и экспорт/импорт с Google Диска), с меньшим количеством строк кода. Процесс для обоих случаев одинаков, потому что прямо сейчас пользователь должен выбрать правильное местоположение для нового файла с помощью экрана SAF — в приложении мы можем получить только URI файла, с которым мы взаимодействовали. Из этого URI мы можем создать правильный поток для чтения или записи в зависимости от наших потребностей. Благодаря SAF я смог значительно упростить логику экрана резервного копирования DaysCounter.

Разрешаете ли вы своим пользователям экспортировать/импортировать данные, которые у них есть в приложении? Если да, то используете ли вы SAF, как я? Что вы думаете об этом? А если нет, то надо! Пользователи очень довольны и считают его полезным (по крайней мере, пользователи DaysCounter 🥰🥰). Дайте мне знать, что вы думаете об этом в комментариях ниже или просто отправьте мне электронное письмо, используя контактную форму.

Читайте также: