Sử dụng API mới

Trang này giải thích cách ứng dụng của bạn có thể sử dụng chức năng hệ điều hành mới khi chạy trên các phiên bản hệ điều hành mới, đồng thời vẫn duy trì khả năng tương thích với các thiết bị cũ.

Theo mặc định, các tệp tham chiếu đến API NDK trong ứng dụng của bạn là tệp tham chiếu mạnh. Trình tải động của Android sẽ sẵn sàng phân giải các lỗi này khi thư viện của bạn được tải. Nếu không tìm thấy các ký hiệu, ứng dụng sẽ huỷ. Điều này trái ngược với cách hoạt động của Java, trong đó ngoại lệ sẽ không được gửi cho đến khi API bị thiếu được gọi.

Vì lý do này, NDK sẽ ngăn bạn tạo các tệp tham chiếu mạnh đến các API mới hơn minSdkVersion của ứng dụng. Điều này giúp bạn không vô tình gửi mã đã hoạt động trong quá trình kiểm thử nhưng sẽ không tải được (UnsatisfiedLinkError sẽ được gửi từ System.loadLibrary()) trên các thiết bị cũ. Mặt khác, việc viết mã sử dụng các API mới hơn minSdkVersion của ứng dụng sẽ khó khăn hơn, vì bạn phải gọi các API bằng dlopen()dlsym() thay vì lệnh gọi hàm thông thường.

Giải pháp thay thế cho việc sử dụng tệp đối chiếu mạnh là sử dụng tệp đối chiếu yếu. Một tệp tham chiếu yếu không được tìm thấy khi thư viện tải dẫn đến địa chỉ của biểu tượng đó được đặt thành nullptr thay vì không tải được. Bạn vẫn không thể gọi các API này một cách an toàn, nhưng miễn là các vị trí gọi được bảo vệ để ngăn việc gọi API khi không có API, thì bạn có thể chạy phần còn lại của mã và gọi API như bình thường mà không cần sử dụng dlopen()dlsym().

Các tệp tham chiếu API yếu không yêu cầu thêm sự hỗ trợ của trình liên kết động, vì vậy, bạn có thể sử dụng các tệp tham chiếu này với bất kỳ phiên bản Android nào.

Bật các tệp tham chiếu API yếu trong bản dựng

CMake

Truyền -DANDROID_WEAK_API_DEFS=ON khi chạy CMake. Nếu bạn đang sử dụng CMake thông qua externalNativeBuild, hãy thêm nội dung sau vào build.gradle.kts (hoặc nội dung tương đương trong Groovy nếu bạn vẫn đang sử dụng build.gradle):

android {
    // Other config...

    defaultConfig {
        // Other config...

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

ndk-build

Thêm đoạn mã sau vào tệp Application.mk:

APP_WEAK_API_DEFS := true

Nếu bạn chưa có tệp Application.mk, hãy tạo tệp đó trong cùng thư mục với tệp Android.mk. Bạn không cần thực hiện thêm thay đổi nào đối với tệp build.gradle.kts (hoặc build.gradle) cho ndk-build.

Các hệ thống xây dựng khác

Nếu bạn không sử dụng CMake hoặc ndk-build, hãy tham khảo tài liệu về hệ thống xây dựng của bạn để xem có cách nào được đề xuất để bật tính năng này hay không. Nếu hệ thống xây dựng của bạn không hỗ trợ tuỳ chọn này theo cách gốc, bạn có thể bật tính năng này bằng cách truyền các cờ sau đây khi biên dịch:

-D__ANDROID_UNAVAILABLE_SYMBOLS_ARE_WEAK__ -Werror=unguarded-availability

Tệp đầu tiên định cấu hình các tiêu đề NDK để cho phép tham chiếu yếu. Phương thức thứ hai chuyển cảnh báo về lệnh gọi API không an toàn thành lỗi.

Hãy xem Hướng dẫn dành cho nhà bảo trì hệ thống xây dựng để biết thêm thông tin.

Lệnh gọi API được bảo vệ

Tính năng này không tự động giúp các lệnh gọi đến API mới an toàn. Việc duy nhất mà phương thức này làm là trì hoãn lỗi thời gian tải thành lỗi thời gian gọi. Lợi ích là bạn có thể bảo vệ lệnh gọi đó trong thời gian chạy và quay lại một cách linh hoạt, cho dù bằng cách sử dụng phương thức triển khai thay thế hay thông báo cho người dùng rằng tính năng đó của ứng dụng không có trên thiết bị của họ hoặc tránh hoàn toàn đường dẫn mã đó.

Clang có thể phát ra cảnh báo (unguarded-availability) khi bạn thực hiện lệnh gọi không được bảo vệ đến một API không có sẵn cho minSdkVersion của ứng dụng. Nếu bạn đang sử dụng ndk-build hoặc tệp chuỗi công cụ CMake, cảnh báo đó sẽ tự động được bật và chuyển thành lỗi khi bạn bật tính năng này.

Dưới đây là ví dụ về một số mã sử dụng có điều kiện một API mà không bật tính năng này, sử dụng dlopen()dlsym():

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);
    }
}

Mã này hơi khó đọc, có một số tên hàm trùng lặp (và nếu bạn đang viết C, thì cả chữ ký cũng vậy), mã này sẽ tạo thành công nhưng luôn sử dụng phương án dự phòng trong thời gian chạy nếu bạn vô tình nhập sai tên hàm được truyền đến dlsym và bạn phải sử dụng mẫu này cho mọi API.

Với các tệp tham chiếu API yếu, hàm ở trên có thể được viết lại như sau:

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

Trong phần nội dung, __builtin_available(android 31, *) gọi android_get_device_api_level(), lưu kết quả vào bộ nhớ đệm và so sánh kết quả đó với 31 (là cấp độ API đã giới thiệu AImageDecoder_resultToString()).

Cách đơn giản nhất để xác định giá trị cần sử dụng cho __builtin_available là cố gắng tạo mà không cần trình bảo vệ (hoặc trình bảo vệ của __builtin_available(android 1, *)) và làm theo thông báo lỗi. Ví dụ: lệnh gọi không được bảo vệ đến AImageDecoder_createFromAAsset() với minSdkVersion 24 sẽ tạo ra:

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

Trong trường hợp này, lệnh gọi phải được __builtin_available(android 30, *) bảo vệ. Nếu không có lỗi bản dựng, thì API luôn có sẵn cho minSdkVersion và không cần có trình bảo vệ, hoặc bản dựng của bạn được định cấu hình không chính xác và cảnh báo unguarded-availability bị tắt.

Ngoài ra, tài liệu tham khảo API NDK sẽ cho biết nội dung tương tự như "Được giới thiệu trong API 30" cho mỗi API. Nếu không có văn bản đó, tức là API có sẵn cho tất cả các cấp độ API được hỗ trợ.

Tránh lặp lại các trình bảo vệ API

Nếu đang sử dụng tính năng này, có thể bạn sẽ có các phần mã trong ứng dụng chỉ có thể sử dụng trên các thiết bị đủ mới. Thay vì lặp lại thao tác kiểm tra __builtin_available() trong từng hàm, bạn có thể chú thích mã của riêng mình là yêu cầu một cấp độ API nhất định. Ví dụ: chính các API ImageDecoder đã được thêm vào API 30, vì vậy, đối với các hàm sử dụng nhiều các API đó, bạn có thể làm như sau:

#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();
    }
}

Các đặc điểm của trình bảo vệ API

Clang rất đặc biệt về cách sử dụng __builtin_available. Chỉ một if (__builtin_available(...)) cố định (mặc dù có thể được thay thế bằng macro) mới hoạt động. Ngay cả các thao tác đơn giản như if (!__builtin_available(...)) cũng sẽ không hoạt động (Clang sẽ phát cảnh báo unsupported-availability-guard cũng như unguarded-availability). Điều này có thể được cải thiện trong phiên bản Clang trong tương lai. Hãy xem Vấn đề 33161 về LLVM để biết thêm thông tin.

Các bước kiểm tra cho unguarded-availability chỉ áp dụng cho phạm vi hàm mà các bước đó được sử dụng. Clang sẽ đưa ra cảnh báo ngay cả khi hàm có lệnh gọi API chỉ được gọi từ trong phạm vi được bảo vệ. Để tránh lặp lại các trình bảo vệ trong mã của riêng bạn, hãy xem phần Tránh lặp lại các trình bảo vệ API.

Tại sao đây không phải là chế độ mặc định?

Trừ khi được sử dụng đúng cách, sự khác biệt giữa tham chiếu API mạnh và tham chiếu API yếu là tham chiếu API mạnh sẽ nhanh chóng và rõ ràng không thành công, trong khi tham chiếu API yếu sẽ không không thành công cho đến khi người dùng thực hiện một hành động khiến API bị thiếu được gọi. Khi điều này xảy ra, thông báo lỗi sẽ không phải là lỗi "AFoo_bar() is not available" (Không có AFoo_bar()) rõ ràng tại thời điểm biên dịch, mà sẽ là lỗi segfault. Với các tệp đối chiếu mạnh, thông báo lỗi sẽ rõ ràng hơn nhiều và lỗi nhanh là một tuỳ chọn mặc định an toàn hơn.

Vì đây là một tính năng mới nên rất ít mã hiện có được viết để xử lý hành vi này một cách an toàn. Mã của bên thứ ba không được viết cho Android có thể sẽ luôn gặp vấn đề này, vì vậy, hiện không có kế hoạch thay đổi hành vi mặc định.

Bạn nên sử dụng tính năng này, nhưng vì tính năng này sẽ khiến bạn khó phát hiện và gỡ lỗi hơn, nên bạn nên chấp nhận những rủi ro đó một cách có ý thức thay vì hành vi thay đổi mà bạn không biết.

Chú ý

Tính năng này hoạt động với hầu hết các API, nhưng có một vài trường hợp không hoạt động.

API libc mới nhất ít có khả năng gây ra vấn đề nhất. Không giống như các API Android còn lại, các API này được bảo vệ bằng #if __ANDROID_API__ >= X trong tiêu đề và không chỉ __INTRODUCED_IN(X), nhờ đó, ngay cả nội dung khai báo yếu cũng không được nhìn thấy. Vì cấp độ API cũ nhất mà NDK hiện đại hỗ trợ là r21, nên các API libc cần thiết nhất đã có sẵn. Các API libc mới được thêm vào mỗi bản phát hành (xem status.md), nhưng càng mới thì càng có nhiều khả năng là trường hợp hiếm gặp mà ít nhà phát triển nào cần đến. Tuy nhiên, nếu là một trong những nhà phát triển đó, thì hiện tại, bạn cần tiếp tục sử dụng dlsym() để gọi các API đó nếu minSdkVersion của bạn cũ hơn API. Đây là vấn đề có thể giải quyết, nhưng việc này có nguy cơ làm hỏng khả năng tương thích nguồn cho tất cả ứng dụng (mọi mã chứa polyfill của API libc sẽ không biên dịch được do các thuộc tính availability không khớp trên libc và các nội dung khai báo cục bộ), vì vậy, chúng tôi không chắc chắn liệu có khắc phục được vấn đề này hay không và khi nào sẽ khắc phục.

Trường hợp mà nhiều nhà phát triển có thể gặp phải là khi thư viện chứa API mới mới hơn minSdkVersion của bạn. Tính năng này chỉ cho phép tham chiếu ký hiệu yếu; không có tham chiếu thư viện yếu. Ví dụ: nếu minSdkVersion là 24, bạn có thể liên kết libvulkan.so và thực hiện lệnh gọi được bảo vệ đến vkBindBufferMemory2, vì libvulkan.so có trên các thiết bị bắt đầu từ API 24. Mặt khác, nếu minSdkVersion là 23, bạn phải quay lại dlopendlsym vì thư viện sẽ không tồn tại trên các thiết bị chỉ hỗ trợ API 23. Chúng tôi không biết giải pháp nào tốt để khắc phục trường hợp này, nhưng về lâu dài, vấn đề này sẽ tự giải quyết vì chúng tôi (bất cứ khi nào có thể) không còn cho phép các API mới tạo thư viện mới.

Đối với tác giả thư viện

Nếu đang phát triển một thư viện để sử dụng trong các ứng dụng Android, bạn nên tránh sử dụng tính năng này trong tiêu đề công khai. Bạn có thể sử dụng tính năng này một cách an toàn trong mã ngoài dòng, nhưng nếu dựa vào __builtin_available trong bất kỳ mã nào trong tiêu đề, chẳng hạn như hàm nội tuyến hoặc định nghĩa mẫu, thì bạn buộc tất cả người dùng phải bật tính năng này. Vì những lý do tương tự, chúng tôi không bật tính năng này theo mặc định trong NDK, bạn nên tránh thay mặt người dùng đưa ra lựa chọn đó.

Nếu bạn yêu cầu hành vi này trong tiêu đề công khai, hãy nhớ ghi lại để người dùng biết rằng họ cần bật tính năng này và nhận thức được các rủi ro khi làm như vậy.