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 trong khi 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 thông tin tham chiếu đến API NDK trong ứng dụng của bạn sẽ là thông tin tham chiếu mạnh mẽ. Trình tải động của Android sẽ nhanh chóng phân giải các lỗi đó khi thư viện của bạn được tải. Nếu không tìm thấy 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, tức là sẽ không gửi một ngoại lệ cho đến khi API bị thiếu được gọi.

Vì lý do này, NDK sẽ ngăn bạn tạo tệp tham chiếu rõ ràng đến các API mới hơn minSdkVersion của ứng dụng. Điều này giúp bạn tránh vô tình truyền mã đã hoạt động trong quá trình kiểm thử nhưng 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, sẽ khó viết mã hơn sử dụng API mới hơn minSdkVersion của ứng dụng vì bạn phải gọi các API bằng dlopen()dlsym() thay vì dùng lệnh gọi hàm thông thường.

Lựa chọn thay thế cho việc sử dụng tham chiếu mạnh là sử dụng tham chiếu yếu. Một tệp tham chiếu yếu không tìm thấy khi thư viện tải sẽ dẫn đến việc đị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 trang web gọi được bảo vệ để ngăn việc gọi API khi API này không có sẵn, thì bạn có thể chạy phần mã còn lại và có thể 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 cần hỗ trợ thêm từ trình liên kết động nên bạn có thể sử dụng các tệp này trên mọi phiên bản Android.

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 đoạn mã sau vào build.gradle.kts (hoặc phiên bản tương đương của 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 này trong cùng thư mục với tệp Android.mk. Bạn không cần thực hiện thêm các thay đổi đối với tệp build.gradle.kts (hoặc build.gradle) đối với ndk-build.

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

Nếu bạn không dùng CMake hoặc ndk-build, hãy tham khảo tài liệu dành cho hệ thống xây dựng để 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ợ sẵn tuỳ chọn này, bạn có thể bật tính năng này bằng cách truyền các cờ sau khi biên dịch:

-D__ANDROID_UNAVAILABLE_SYMBOLS_ARE_WEAK__ -Werror=unguarded-availability

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

Xem Hướng dẫn 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 thực hiện các lệnh gọi đến API mới. Điều duy nhất mà nó 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 hoạt động 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 dành 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, thì cảnh báo đó sẽ tự động được bật và hiển thị thành lỗi khi bật tính năng này.

Dưới đây là ví dụ về một số mã sử dụng API có điều kiện 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);
    }
}

Việc đọc hơi lộn xộn, tên hàm bị trùng lặp (và nếu bạn đang viết C cũng như chữ ký), thì tên hàm sẽ được tạo thành công nhưng luôn sử dụng tính năng dự phòng trong thời gian chạy nếu bạn vô tình đánh máy tên hàm được chuyển vào dlsym và bạn phải sử dụng mẫu này cho mọi API.

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

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

Trong trường hợp này, __builtin_available(android 31, *) sẽ gọi android_get_device_api_level(), lưu kết quả vào bộ nhớ đệm và so sánh với 31 (là cấp độ API đã ra mắt 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à thực hiện những gì thông báo lỗi cho bạn biết. Ví dụ: lệnh gọi không bảo vệ đến AImageDecoder_createFromAAsset()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, cuộc gọi phải được bảo vệ bằng __builtin_available(android 30, *). Nếu không có lỗi bản dựng, thì API luôn có sẵn cho minSdkVersion của bạn và không cần biện pháp bảo vệ, hoặc bản dựng của bạn bị định cấu hình sai 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 "Đã ra mắt trong API 30" cho từng API. Nếu bạn không thấy văn bản đó, tức là API này có sẵn cho mọi cấp độ API được hỗ trợ.

Tránh lặp lại các biện pháp bảo vệ API

Nếu đang sử dụng phương thức này, ứng dụng của bạn có thể sẽ có một số phần mã chỉ dùng được trên thiết bị mới. Thay vì lặp lại bước kiểm tra __builtin_available() trong mỗi 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ụ: Bản thân 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 API đó, bạn có thể làm những việc như:

#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 nút lệnh của trình bảo vệ API

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

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

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

Trừ phi đượ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 sẽ bị lỗi nhanh chóng và rõ ràng, trong khi tham chiếu sau sẽ không bị lỗi cho đến khi người dùng thực hiện 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ó sẵn) trong thời gian biên dịch mà sẽ là một lỗi segfault. Với tệp tham chiếu mạnh, thông báo lỗi sẽ rõ ràng hơn nhiều và giá trị mặc định không thành công là 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 bằng Android có thể sẽ luôn gặp vấn đề này. Vì vậy, hiện không có kế hoạch nào để hành vi mặc định thay đổi.

Bạn nên sử dụng phương thức này, nhưng vì việc này sẽ khiến việc phát hiện và gỡ lỗi vấn đề trở nên khó khăn hơn. Do đó, bạn nên cố ý chấp nhận những rủi ro đó 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.

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

Nhiều nhà phát triển có thể gặp phải trường hợp này 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 các tham chiếu biểu tượng yếu; không có cái gọi là tham chiếu thư viện yếu. Ví dụ: nếu minSdkVersion của bạn 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ó sẵn trên các thiết bị bắt đầu bằng API 24. Mặt khác, nếu minSdkVersion của bạn là 23, bạn phải quay lại dùng dlopendlsym vì thư viện sẽ không tồn tại trên thiết bị này trên các thiết bị chỉ hỗ trợ API 23. Chúng tôi chưa biết giải pháp hiệu quả để khắc phục trường hợp này, nhưng về lâu dài, giải pháp đó 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 API mới tạo thư viện mới nữa.

Dành cho tác giả thư viện

Nếu đang phát triển một thư viện để dùng trong các ứng dụng Android, bạn nên tránh dùng tính năng này trong các 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 tuyến, 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ư các hàm cùng dòng hoặc định nghĩa mẫu, thì bạn sẽ buộc tất cả người tiêu dùng bật tính năng này. Vì các 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 tiêu dùng đưa ra lựa chọn đó.

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