Korzystanie z nowszych interfejsów API

Na tej stronie wyjaśniamy, w jaki sposób aplikacja może korzystać z nowych funkcji systemu operacyjnego w nowych wersjach systemu operacyjnego przy zachowaniu zgodności ze starszymi urządzeniami.

Domyślnie odniesienia do interfejsów API NDK w aplikacji są silnymi odniesieniami. Gdy Twoja biblioteka zostanie załadowana, dynamicznie ładowarka Androida szybko je rozwiąże. Jeśli symbole nie zostaną znalezione, aplikacja przerwie działanie. Jest to sprzeczne z działaniem Javy, w którym wyjątek nie zostanie zgłoszony, dopóki nie zostanie wywołany brakujący interfejs API.

Z tego powodu NDK uniemożliwia tworzenie silnych odwołań do interfejsów API nowszych niż minSdkVersion Twojej aplikacji. Chroni to przed przypadkowym kodem dostawy, który zadziałał podczas testów, ale nie zostanie wczytany (UnsatisfiedLinkError zostanie wywołany z System.loadLibrary()) na starszych urządzeniach. Z drugiej strony pisanie kodu korzystającego z interfejsów API nowszych niż minSdkVersion aplikacji jest trudniejsze, ponieważ musisz wywoływać te interfejsy API za pomocą elementów dlopen() i dlsym(), a nie zwykłego wywołania funkcji.

Alternatywą dla silnych odwołań są używanie słabych plików referencyjnych. Słabe odwołanie, którego nie można znaleźć, gdy biblioteka jest wczytywana, adres tego symbolu jest ustawiony na nullptr, a nie ładuje się. Nadal nie można ich bezpiecznie wywoływać, ale jeśli witryny wywołujące są zabezpieczone, aby uniemożliwić wywoływanie interfejsu API, gdy jest on niedostępny, reszta kodu może zostać uruchomiona i można je wywoływać w normalny sposób bez konieczności używania elementów dlopen() i dlsym().

Słabe odniesienia do interfejsu API nie wymagają dodatkowej obsługi dynamicznego tagu łączącego, więc można ich używać z dowolną wersją Androida.

Włączanie w kompilacji słabych odwołań do interfejsu API

CMake

Przekazuj -DANDROID_WEAK_API_DEFS=ON podczas uruchamiania CMake. Jeśli używasz CMake przez externalNativeBuild, dodaj do build.gradle.kts (lub jego odpowiednika, jeśli nadal używasz build.gradle):

android {
    // Other config...

    defaultConfig {
        // Other config...

        externalNativeBuild {
            cmake {
                arguments.add("-DANDROID_WEAK_API_DEFS=ON")
                // Other config...
            }
        }
    }
}

ndk-build

Dodaj do pliku Application.mk te informacje:

APP_WEAK_API_DEFS := true

Jeśli nie masz jeszcze pliku Application.mk, utwórz go w tym samym katalogu co plik Android.mk. W przypadku polecenia ndk-build nie są konieczne dodatkowe zmiany w pliku build.gradle.kts (lub build.gradle).

Inne systemy kompilacji

Jeśli nie używasz CMake ani ndk-build, zapoznaj się z dokumentacją systemu kompilacji, aby sprawdzić, czy istnieje zalecany sposób włączenia tej funkcji. Jeśli Twój system kompilacji nie obsługuje natywnie tej opcji, możesz ją włączyć, przesyłając te flagi podczas kompilacji:

-D__ANDROID_UNAVAILABLE_SYMBOLS_ARE_WEAK__ -Werror=unguarded-availability

Pierwsza konfiguruje nagłówki NDK tak, aby zezwalały na słabe odwołania. Po drugie ostrzeżenie o niebezpiecznych wywołaniach interfejsu API zmienia się w błąd.

Więcej informacji znajdziesz w przewodniku dla właścicieli systemów tworzenia systemów.

Chronione wywołania interfejsu API

Ta funkcja nie zapewnia magicznego bezpieczeństwa wywoływania nowych interfejsów API. Jedyne, co robi, to odkłada błąd czasu wczytywania do błędu związanego z czasem połączenia. Zaletą jest to, że możesz zabezpieczyć to wywołanie w czasie działania i płynnie się wycofać, stosując implementację alternatywną, powiadamiając użytkownika, że dana funkcja aplikacji jest niedostępna na jego urządzeniu, lub całkowicie omija tę ścieżkę kodu.

Clang może generować ostrzeżenie (unguarded-availability) przy niezabezpieczonym wywołaniu interfejsu API, który jest niedostępny dla interfejsu minSdkVersion aplikacji. Jeśli używasz narzędzia ndk-build lub naszego pliku łańcucha narzędzi CMake, to ostrzeżenie zostanie automatycznie włączone i oznaczone jako błąd przy włączaniu tej funkcji.

Oto przykład kodu, który za pomocą właściwości dlopen() i dlsym() korzysta z warunkowego użycia interfejsu API bez włączonej tej funkcji:

void LogImageDecoderResult(int result) {
    void* lib = dlopen("libjnigraphics.so", RTLD_LOCAL);
    CHECK_NE(lib, nullptr) << "Failed to open libjnigraphics.so: " << dlerror();
    auto func = reinterpret_cast<decltype(&AImageDecoder_resultToString)>(
        dlsym(lib, "AImageDecoder_resultToString")
    );
    if (func == nullptr) {
        LOG(INFO) << "cannot stringify result: " << result;
    } else {
        LOG(INFO) << func(result);
    }
}

Trudno to odczytać. Niektóre nazwy funkcji są zduplikowane (a jeśli piszesz w języku C, również w podpisach), kompiluje się ona prawidłowo, ale jeśli przypadkowo wpiszesz nazwę funkcji przekazaną do dlsym, i musisz używać tego wzorca w każdym interfejsie API, działanie kreacji zastępczej jest stosowane w czasie działania.

W przypadku słabych odwołań do interfejsu API funkcję powyższą można zapisać tak:

void LogImageDecoderResult(int result) {
    if (__builtin_available(android 31, *)) {
        LOG(INFO) << AImageDecoder_resultToString(result);
    } else {
        LOG(INFO) << "cannot stringify result: " << result;
    }
}

Znajdujące się w niej interfejs __builtin_available(android 31, *) wywołuje android_get_device_api_level(), zapisuje wynik w pamięci podręcznej i porównuje go z 31 (czyli poziomem interfejsu API, który umożliwił korzystanie z AImageDecoder_resultToString()).

Najprostszym sposobem określenia, której wartości użyć dla funkcji __builtin_available, jest próba kompilacji bez zabezpieczenia (lub zabezpieczenia __builtin_available(android 1, *)) i skorzystanie z informacji podanych w komunikacie o błędzie. Na przykład niechronione wywołanie metody AImageDecoder_createFromAAsset() przy użyciu metody minSdkVersion 24 zwróci:

error: 'AImageDecoder_createFromAAsset' is only available on Android 30 or newer [-Werror,-Wunguarded-availability]

W tym przypadku połączenie powinno być chronione przez __builtin_available(android 30, *). Jeśli nie wystąpi błąd kompilacji, interfejs API jest zawsze dostępny dla Twojej instancji minSdkVersion i ochrona nie jest potrzebna albo kompilacja jest nieprawidłowo skonfigurowana i ostrzeżenie unguarded-availability jest wyłączone.

Alternatywnie w dokumentacji interfejsu NDK API pojawi się informacja, że dla każdego interfejsu API pojawi się informacja „Wprowadzono w interfejsie API 30”. Brak tego tekstu oznacza, że dany interfejs API jest dostępny dla wszystkich obsługiwanych poziomów interfejsu API.

Unikanie powtarzania zabezpieczeń interfejsu API

Jeśli korzystasz z tego sposobu, prawdopodobnie masz w aplikacji sekcje kodu, których można używać tylko na wystarczającej liczbie nowych urządzeń. Zamiast powtarzać funkcję __builtin_available() przy każdej funkcji, możesz oznaczyć własny kod jako wymagający określonego poziomu interfejsu API. Na przykład interfejsy ImageDecoder API zostały dodane w interfejsie API 30, więc w przypadku funkcji, które intensywnie z nich korzystają, możesz wykonać takie działanie:

#define REQUIRES_API(x) __attribute__((__availability__(android,introduced=x)))
#define API_AT_LEAST(x) __builtin_available(android x, *)

void DecodeImageWithImageDecoder() REQUIRES_API(30) {
    // Call any APIs that were introduced in API 30 or newer without guards.
}

void DecodeImageFallback() {
    // Pay the overhead to call the Java APIs via JNI, or use third-party image
    // decoding libraries.
}

void DecodeImage() {
    if (API_AT_LEAST(30)) {
        DecodeImageWithImageDecoder();
    } else {
        DecodeImageFallback();
    }
}

Dziwności zabezpieczeń interfejsów API

W języku angielskim bardzo ważny jest sposób korzystania z usługi __builtin_available. Działa tylko literał (choć może również zastąpić makro) if (__builtin_available(...)). Nawet proste operacje, takie jak if (!__builtin_available(...)), nie będą działać (Clang będzie wyświetlać ostrzeżenie unsupported-availability-guard, a także unguarded-availability). Może to poprawić się w przyszłej wersji Clang. Więcej informacji znajdziesz w problemie z LLVM 33161.

Kontrole funkcji unguarded-availability mają zastosowanie tylko do zakresu funkcji, w którym są używane. Clang wygeneruje ostrzeżenie nawet wtedy, gdy funkcja zawierająca wywołanie interfejsu API zostanie wywołana tylko z chronionego zakresu. Aby uniknąć powtarzania zabezpieczeń w kodzie, przeczytaj artykuł Unikanie powtarzania zabezpieczeń interfejsu API.

Dlaczego nie jest to ustawienie domyślne?

Jeśli nie użyto prawidłowo, różnica między silnymi odwołaniami do interfejsów API a słabymi odwołaniami polega na tym, że pierwsze z nich szybko i wyraźnie zawiodą, a późniejszy zakończy się niepowodzeniem, dopóki użytkownik nie wykona działania, które spowoduje wywołanie brakującego interfejsu API. W takim przypadku komunikat o błędzie nie będzie wyraźnym komunikatem o błędzie „AFoo_bar() nie jest dostępny” podczas kompilacji. Będzie to segfault. Przy silnym odniesieniu komunikat o błędzie jest znacznie bardziej zrozumiały, a z powodu braku szybkości jest bezpieczniejszą opcją domyślną.

Ponieważ jest to nowa funkcja, piszemy bardzo niewiele istniejącego kodu, który ma zapewnić bezpieczną obsługę. Kody zewnętrzne, które nie zostały napisane z myślą o Androidzie, prawdopodobnie zawsze będą mieć ten problem, dlatego na razie nie ma planów na to, by domyślne działanie mogło się zmienić.

Zalecamy korzystanie z tego rozwiązania, ale ponieważ utrudni to wykrycie i debugowanie problemów, lepiej podejść do tego ryzyka świadomie, zamiast robić to bez Twojej wiedzy.

Uwagi

Ta funkcja działa w przypadku większości interfejsów API, ale w niektórych przypadkach może nie działać.

Najmniejszym problemem są nowsze interfejsy API libc. W przeciwieństwie do pozostałych interfejsów API Androida zabezpieczenia te są oznaczone w nagłówkach znakiem #if __ANDROID_API__ >= X, a nie tylko __INTRODUCED_IN(X), co uniemożliwia wyświetlenie nawet słabej deklaracji. Ponieważ najstarszy nowoczesny poziom pakietów NDK obsługiwanych przez NDK na poziomie interfejsu API to r21, najbardziej potrzebne interfejsy API libc są już dostępne. W każdej wersji są dodawane nowe interfejsy libc API (patrz status.md), ale im są nowsze, tym większe prawdopodobieństwo, że będą one problemem ekstremalnym, którego będzie potrzebować niewielu deweloperów. Jeśli jesteś jednym z tych programistów, na razie musisz nadal używać dlsym() do wywoływania tych interfejsów API, jeśli minSdkVersion jest starszy niż API. Jest to problem, który można rozwiązać, ale wiąże się to z ryzykiem naruszenia zgodności źródeł w przypadku wszystkich aplikacji (kod zawierający kod zawierający elementy polyfill interfejsów API libc nie uda się skompilować z powodu niezgodności atrybutów availability w bibliotece libc i deklaracjach lokalnych), dlatego nie jesteśmy pewni, czy i kiedy to poprawimy.

Więcej deweloperów może się zetknąć, jeśli biblioteka zawierająca nowy interfejs API jest nowsza niż minSdkVersion. Ta funkcja umożliwia stosowanie tylko słabych odwołań do symboli; nie ma czegoś takiego jak słabe odniesienia do biblioteki. Jeśli na przykład minSdkVersion ma numer 24, możesz połączyć libvulkan.so i wywołać zabezpieczone wywołanie funkcji vkBindBufferMemory2, ponieważ funkcja libvulkan.so jest dostępna na urządzeniach, które zaczynają się od interfejsu API 24. Jeśli natomiast masz minSdkVersion w wieku 23 lat, musisz wrócić do wersji dlopen i dlsym, bo biblioteka nie będzie istnieć na urządzeniu, które obsługują tylko interfejs API 23. Nie wiemy, jakie jest dobre rozwiązanie tego problemu, ale na dłuższą metę unikniesz tego samego, bo (gdy tylko będzie to możliwe) przestajemy zezwalać nowym interfejsom API na tworzenie nowych bibliotek.

Dla autorów z biblioteki

Jeśli tworzysz bibliotekę do użytku w aplikacjach na Androida, unikaj używania tej funkcji w nagłówkach publicznych. Można go bezpiecznie używać w kodzie poza wierszem, ale jeśli polegasz na kodzie __builtin_available w nagłówkach, np. w funkcjach wbudowanych lub definicjach szablonów, zmuszasz wszystkich klientów do włączenia tej funkcji. Z tych samych powodów, dla których domyślnie nie włączamy tej funkcji w NDK, należy unikać dokonywania tego wyboru w imieniu klientów.

Jeśli wymagasz takiego zachowania w nagłówkach publicznych, udokumentuj to. Dzięki temu zarówno użytkownicy będą wiedzieć, że będą musieli włączyć tę funkcję, oraz będą świadomi ryzyka związanego z tą zmianą.