Jak zyskać łatwy dostęp do plików na Androidzie? Łączenie Storage Access Framework z Activity Result API

Jak zyskać łatwy dostęp do plików na Androidzie? Łączenie Storage Access Framework z Activity Result API

Status relacji między programistą a Storage Access Framework (SAF) najlepiej określić jako Skomplikowany. Owszem, SAF daje Ci dostęp do plików, ale jest tak denerwujący, że chcesz zamknąć laptopa i sięgnąć po kubek uspokajającej herbaty. Na szczęście jest światło w tym tunelu. SAF opiera się na mechanizmach activity results – możesz połączyć go z Activity Results API i cieszyć się uporządkowaną strukturą kodu. Sprawdź, jak to zrobić.

Ze Storage Access Framework warto korzystać tylko w określonych przypadkach. Chcesz odczytywać zdjęcia, wideo albo dane z wewnętrznej pamięci aplikacji? Są na to inne sposoby, które nie bazują na zewnętrznym document providerze i lepiej spełniają to zadanie. Zanim zaczniesz głębiej poznawać możliwości Storage Access Framework, przeczytaj, co o   przechowywaniu danych i plików pisze Google i zobacz, jaka metoda dostępu najlepiej odpowiada Twoim potrzebom.

Dlaczego sam Storage Access Framework już nie wystarcza?

Android Storage Access Framework nie jest niczym nowym, jeśli chodzi o dostęp do pamięci i zarządzanie plikami – jest z nami od Androida 4.4. Jednak dopiero od niedawna cieszy się większym uznaniem. Wszystko dzięki publikacji Androida Q i wymogowi stosowania modelu Scoped Storage w aplikacjach, który zresztą spotkał się z chłodnym przyjęciem.

Odkąd wprowadzono Scope Storage, Storage Access Framework wydaje się być jedyną rzetelną opcją, która daje dostęp do plików urządzenia – chociaż programiści nadal nie mogą zaliczyć tego do wygodnych rozwiązań. API samo w sobie jest nieco dziwne, w dokumentacji brakuje ważnych elementów, nie wspominając już o problemach z wydajnością i nieoczekiwanymi zachowaniami. Korzystanie z SAF z pewnością nie należy do najprzyjemniejszych elementów budowy aplikacji na Androida.

Ale czy się to komuś podoba, czy nie – Storage Access Framework pozostanie z nami jeszcze przez jakiś czas. Dlatego najlepiej zastanowić się, jak ułatwić sobie korzystanie z niego na co dzień. Połączenie SAF z Activity Result API to dobre rozwiązanie na początek.

Czym jest Activity Result API?

To API pozwala zapomnieć o starym podejściu z wykorzystaniem startActivityForResult() i umożliwia bezpośrednią obsługę zewnętrznych activity intents razem z ich rezultatami.

Activity Result API wprowadzono w modułach androidx.activity:activity i androidx.fragment:fragment.

Koncept jest dosyć prosty:

  1. Stwórz implementację ActivityResultContract z dwiema nadpisanymi metodami – createIntent() zwracającą wartość Intent, której chcesz użyć do otwarcia zewnętrznej Activity oraz parseResult() – ta metoda zwraca rezultat, który odczytujesz z Intent, zwrócony przez zewnętrzną Activity.
  2. Zarejestruj stworzony kontrakt w ActivityResultRegistry (nie martw się, dla Fragmentu i Activity też jest wiele wygodnych metod) razem z callbackiem, który zostanie uruchomiony, gdy zewnętrzna Activity dostarczy rezultaty. To wywołanie otrzyma rezultat z implementacji parseResult() wewnętrz kontraktu.
  3. Uruchom ActivityResultLauncher otrzymany dzięki rejestracji ActivityResultContract i obserwuj, jak Activity zostaje obsługiwana.

Proces tworzenia dedykowanego ActivityResultContract jest łatwy, ale żeby jeszcze bardziej go uprościć, Google oferuje popularne implementacje kontraktu razem z API. To pozwala Ci wykorzystać gotowe kontrakty TakePicture lub RequestPermission.

Korzyści z korzystania z Activity Result API

Activity Result API może nie wydawać się zbyt ciekawe – ostatecznie to tylko wrapper zbudowany wokół startActivityForResult() i onActivityResult(). Ale nawet takie małe ułatwienie w obsłudze Android Intents daje korzyści w zakresie rozwoju aplikacji:

  • O wiele mniej powtarzalnego kodu w elementach Fragment/Activity – nie ma potrzeby nadpisywania onActivityResult() ani weryfikacji wartości requestCode w celu sprawdzenia czy rezultat pochodzi z Activity zgodnie z oczekiwaniami. Wystarczy, że uruchomisz kontrakt i obsłużysz rezultat w callbacku.
  • Możliwość wykorzystania poza Activity – API musi mieć dostęp do niektórych komponentów z  ActivityResultRegistry , ale tylko do rejestracji launchera. Wszystkie inne komponenty mogą funkcjonować bez żadnego odnośnika do niego, co pomaga wyciągnąć część powiązanej logiki z Activity.
  • Łatwość w odczytywaniu i zrozumieniu – to API jest bardziej przejrzyste i lepiej uporządkowane w porównaniu z klasycznym podejściem. Znacznie łatwiej zrozumieć też jego działanie. Jeden launcher wykonuje jeden kontrakt i zwraca jego rezultaty do jednego callbacku – bez żadnych kodów ani metod pomiędzy.

Biorąc pod uwagę korzyści, Activity Results API jest zdecydowanie wart uwagi. Powoli staje się nawet branżowym standardem i zastępuje stare dobre podejście z wykorzystaniem  startActivityForResult().

Implementacja Storage Access Framework z Activity Results API

Wspomniałem, że klasa ActivityResultContract oferuje predefiniowane implementacje dla najpopularniejszych przypadków związanych z elementem Intent. Wdraża także pewne operacje ze Storage Access Framework, jak np. CreateDocument lub OpenDocument. Możesz korzystać z tych kontraktów, żeby otwierać widok wyboru plików, stworzony na Document Providers.

Storage Access Framwork view

Storage Access Framework view
Widok wyboru plików otwarty za pomocą Storage Access Framework

Domyślne implementacje nie oferują zbyt wielu opcji personalizacji i wymuszają pewne parametry, takie jak np. sugerowana nazwa dokumentu  Na szczęście nic nie stoi na przeszkodzie, żebyś sam zbudował je od zera. Możesz ustawić różne parametry przekazywane do elementu Intent, które sprawdzą się w Twoim przypadku.

W mojej aplikacji chciałem dodać ogólną implementację, która pozwala łatwo wybierać lub tworzyć pliki tekstowe. Dlatego podzieliłem mój kod i stworzyłem:

  • dwie implementacje ResultContract – jedna do tworzenia, a druga do wybierania plików,
  • klasę interactor do wydobycia i przeniesienia kontraktów rejestrujących oraz uruchamiających z Fragment/Activity do bardziej ogólnego miejsca, które można powtórnie wykorzystać,
  • interfejs wdrożony poprzez obiekt Fragment , który służy jako callback do obsługi rezultatów.

Ta konstrukcja może posłużyć jako przykład. W przyszłości można ją będzie z łatwością rozbudowywać.

A teraz, bez zbędnego przedłużania – przejdźmy do kodu!

Implementacja ResultContract do tworzenia i wybierania plików

Możesz określić typ pliku, który chcesz wybrać poprzez podanie wspieranego MIME type. Dla ułatwienia przygotowałem prostą klasę obsługi danych, która przechowuje ten parametr:

data class SelectFileParams(
    val fileMimeType: String
)

Pamiętaj W Androidzie kapitaliki mają znaczenie, jeśli chodzi o MIME types. Dlatego format application/json zadziała, ale application/JSON już nie. Żeby uniknąć problemów, możesz wpisać normalizeMimeType() i w ten sposób ustawić małe litery MIME type albo wstawić setTypeAndNormalize() w Intent, żeby zyskać pewność, że Twój format MIME type jest poprawny.

Moja implementacja SelectFileResultContract uzna tę klasę danych jako parametr wejściowy i zwróci obiekt Uri wskazujący na plik, który wybrał użytkownik.

class SelectFileResultContract : ActivityResultContract<SelectFileParams, Uri?>() {

    override fun createIntent(context: Context, data: SelectFileParams): Intent =
        Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
            addCategory(Intent.CATEGORY_OPENABLE)
            setTypeAndNormalize(data.fileMimeType)
        }

    override fun parseResult(resultCode: Int, intent: Intent?): Uri? = when (resultCode) {
        Activity.RESULT_OK -> intent?.data
        else -> null
    }
}

Przy tworzeniu nowych plików możesz określić więcej parametrów poza MIME type – takich jak np. sugerowana nazwa i rozszerzenie dla Twojego nowego pliku. Pamiętaj, że użytkownicy będą mogli je edytować po otwarciu widoku z plikami w Storage Access Framework.

W przypadku, gdy podana nazwa pliku jest już zajęta, zobaczysz numer, który pojawi się automatycznie obok nazwy.

Z tymi dodatkowymi parametrami moja klasa danych ma więcej pól:

data class CreateFileParams(
    val fileMimeType: String,
    val fileExtension: String,
    val suggestedName: String
)

Implementacja CreateFileResultContract wykorzysta te parametry jako dane wejściowe i zastosuje je w elementcie Intent. Moje dane wejściowe są takie same jak w poprzednim przykładzie – obiekt Uri wskazujący na stworzony dokument.

class CreateFileResultContract : ActivityResultContract<CreateFileParams, Uri?>() {

    override fun createIntent(context: Context, data: CreateFileParams): Intent =
        Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
            addCategory(Intent.CATEGORY_OPENABLE)
            setTypeAndNormalize(data.fileMimeType)
            putExtra(Intent.EXTRA_TITLE, "${data.suggestedName}.${data.fileExtension}")
        }

    override fun parseResult(resultCode: Int, intent: Intent?): Uri? = when (resultCode) {
        Activity.RESULT_OK -> intent?.data
        else -> null
    }
}

Jeśli klasy ActivityResultContract są już gotowe, możesz kontynuować implementację logiki, która je uruchomi.

Implementacja interakcji z Activity Results API

Na początek przygotuj interfejs albo klasę abstrakcyjną. Activity albo Fragment wdroży ją, żeby umożliwić dostęp do ActivityResultRegistry i obsłużyć rezultaty publikacji.

interface FileSelectionEntryPoint {

    val fileSelectionOwner: Fragment

    fun onFileCreated(fileDescriptor: FileDescriptor?)

    fun onFileSelected(fileDescriptor: FileDescriptor?)
}

Implementacje klasy przekażesz do swojego interactora poprzez jego konstruktora. Musisz wykorzystać właśnie tę strukturę z powodu wymagania, które bierze się z registerForActivityResult(). ActivityResultContracts może być zarejestrowane tylko wtedy, kiedy ich elementy Fragment albo Activity są już utworzone.

Próba zarejestrowania go później skończy się tym, że aplikacja przestanie działać, więc musisz wywołać tę metodę, kiedy tworzy się StorageAccessFrameworkInteractor – to jedyny sposób.

class StorageAccessFrameworkInteractor(private val fileSelectionEntryPoint: FileSelectionEntryPoint) {

    private val createFileLauncher: ActivityResultLauncher<CreateFileParams> =
        fileSelectionEntryPoint.fileSelectionOwner
            .registerForActivityResult(CreateFileResultContract()) { uri ->
                // TODO: Handle result
            }

    private val selectFileLauncher: ActivityResultLauncher<SelectFileParams> =
        fileSelectionEntryPoint.fileSelectionOwner
            .registerForActivityResult(SelectFileResultContract()) { uri ->
                // TODO: Handle result
            }
}

Po ustawieniu launcherów możesz dodać więcej metod, które obsłużą ich rezultaty i przekażą je do callbacków FileSelectionEntryPoint. W mojej implementacji uzyskałem ContentResolver z obiektu Fragment i wykorzystałem go do stworzenia FileDescriptor z Uri, ale nie jest to konieczne. Możesz po prostu przekazać obiekt Uri – w zależności od tego, czy w Twoim przypadku jest taka opcja i jakie są wymagania związane z dalszym przetwarzaniem.

Musisz też dodać metody, żeby uruchomić instancje ActivityResultLauncher. Ostatecznie Twoja implementacja powinna wygląd podobnie do tej:

class StorageAccessFrameworkInteractor(private val fileSelectionEntryPoint: FileSelectionEntryPoint) {

    private val createFileLauncher: ActivityResultLauncher<CreateFileParams> =
        fileSelectionEntryPoint.fileSelectionOwner
            .registerForActivityResult(CreateFileResultContract()) { uri ->
                onFileCreationFinished(uri)
            }

    private val selectFileLauncher: ActivityResultLauncher<SelectFileParams> =
        fileSelectionEntryPoint.fileSelectionOwner
            .registerForActivityResult(SelectFileResultContract()) { uri ->
                onFileSelectionFinished(uri)
            }

    private fun beginCreatingFile(createFileParams: CreateFileParams) =
        createFileLauncher.launch(createFileParams)

    private fun onFileCreationFinished(fileUri: Uri?) {
        val fileDescriptor = fileUri?.let { uri ->
            fileSelectionEntryPoint.fileSelectionOwner
                .requireContext()
                .contentResolver
                .openFileDescriptor(uri, "w")
                ?.fileDescriptor
        }

        fileSelectionEntryPoint.onFileCreated(fileDescriptor)
    }

    private fun beginSelectingFile(selectFileParams: SelectFileParams) =
        selectFileLauncher.launch(selectFileParams)

    private fun onFileSelectionFinished(fileUri: Uri?) {
        val fileDescriptor = fileUri?.let { uri ->
            fileSelectionEntryPoint.fileSelectionOwner
                .requireContext()
                .contentResolver
                .openFileDescriptor(uri, "r")
                ?.fileDescriptor
        }

        fileSelectionEntryPoint.onFileSelected(fileDescriptor)
    }
}

Teraz masz implementację interactora, która:

  • uruchomi Twoją implementację ActivityResultContract,
  • przetwarza rezultaty implementacji,
  • przekaże dane z powrotem do każdego Fragmentu, który implementuje interfejs FileSelectionEntryPoint.

Korzystanie z implementacji we fragmentach

Minimalna implementacja Twojego interfejsu w obiekcie Fragment musi dostarczyć swoją referencję, by móc wywołać registerForActivityResult() w interactorze. Pozwoli też na wdrożenie metod obsługi wyboru i tworzenia plików. Może wyglądać w ten sposób:

class FileTestFragment : Fragment(R.layout.file_test_fragment), FileSelectionEntryPoint {

    override val fileSelectionOwner: Fragment = this
    private val fileSelectionInteractor: StorageAccessFrameworkInteractor =
        StorageAccessFrameworkInteractor(this)

    fun onCreateFileClick(createFileParams: CreateFileParams) =
        fileSelectionInteractor.beginCreatingFile(createFileParams)

    override fun onFileCreated(file: FileDescriptor?) {
        // TODO: Handle file descriptor of created file
    }

    fun onSelectFileClick(selectFileParams: SelectFileParams) =
        fileSelectionInteractor.beginSelectingFile(selectFileParams)

    override fun onFileSelected(fileDescriptor: FileDescriptor?) {
        // TODO: Handle file descriptor of selected file
    }
}

Od tego momentu możesz rozwijać funkcjonalność swojej implementacji i rozszerzać jej możliwości.

 

Activity Results API zwiększa produktywność programistów i czyni ich szczęśliwszymi ludźmi. Razem z innymi komponentami, które bazują na mechanizmach activity results (takimi jak Storage Access Framework), ułatwia budowę aplikacji bez szkody dla funkcjonalności. Jednocześnie kod pozostaje prosty i nieskomplikowany. Zalecam przetestowanie tego rozwiązania każdemu, kto programuje aplikacje na Androida.

Igor

Igor Kurek

Software Developer z ponad 4-letnim doświadczeniem w rozwijaniu aplikacji na Androida. Igor lubi tworzyć oprogramowanie zarówno w pracy, jak i po godzinach. Kiedy akurat nie wpatruje się w ekran, jego myśli zajmują różnego rodzaju kwestie związane z muzyką oraz literatura współczesna.

Dowiedz się więcej

Wycena projektu

Opowiedz nam o swoim projekcie i napisz, jak możemy Ci pomóc.

Dlaczego warto rozwijać z nami projekty?

Logo Mobile Trends Awards

Mobile Trends Awards 2021

Wygrana w kategorii
ŻYCIE CODZIENNE

Nagroda Legalnych Bukmacherów

Nagroda Legalnych Bukmacherów 2019

Najlepsza aplikacja mobilna

Mobile Trends Awards logo

Mobile Trends Awards 2023

Wygrana w kategorii
MCOMMERCE ROZWÓJ

23

opinie klientów

Clutch logo