การใช้ API ที่ใหม่กว่า

หน้านี้อธิบายวิธีที่แอปของคุณสามารถใช้ฟังก์ชันการทำงานใหม่ของระบบปฏิบัติการเมื่อทำงานบนระบบปฏิบัติการเวอร์ชันใหม่ ขณะเดียวกันก็ยังคงใช้งานร่วมกับอุปกรณ์รุ่นเก่าได้

โดยค่าเริ่มต้น การอ้างอิง NDK API ในแอปพลิเคชันของคุณจะเป็นการอ้างอิงแบบเข้มงวด โปรแกรมโหลดแบบไดนามิกของ Android จะแก้ไขข้อผิดพลาดเหล่านี้เมื่อโหลดไลบรารี หากไม่พบสัญลักษณ์ แอปจะหยุดดำเนินการ ซึ่งขัดต่อลักษณะการทํางานของ Java ที่จะไม่แสดงข้อยกเว้นจนกว่าจะเรียก API ที่ขาดหายไป

ด้วยเหตุนี้ NDK จึงป้องกันไม่ให้คุณสร้างการอ้างอิงที่ชัดเจนถึง API ที่ใหม่กว่า minSdkVersion ของแอป วิธีนี้จะช่วยป้องกันไม่ให้คุณเผยแพร่โค้ดที่ใช้งานได้ในระหว่างการทดสอบโดยไม่ได้ตั้งใจ แต่โหลดไม่สำเร็จ (UnsatisfiedLinkError จะแสดงจาก System.loadLibrary()) ในอุปกรณ์รุ่นเก่า ในทางกลับกัน การเขียนโค้ดที่ใช้ API ใหม่กว่า minSdkVersion ของแอปจะยากกว่า เนื่องจากคุณต้องเรียก API โดยใช้ dlopen() และ dlsym() แทนการเรียกฟังก์ชันตามปกติ

ทางเลือกในการใช้ข้อมูลอ้างอิงที่รัดกุมคือการใช้ข้อมูลอ้างอิงที่ไม่รัดกุม การอ้างอิงแบบไม่แน่ชัดที่ระบบไม่พบเมื่อโหลดไลบรารีจะส่งผลให้ระบบตั้งค่าที่อยู่ของสัญลักษณ์นั้นเป็น nullptr แทนที่จะโหลดไม่สำเร็จ ยังคงเรียกใช้ API เหล่านี้ได้อย่างไม่ปลอดภัย แต่ตราบใดที่จุดเรียกใช้ได้รับการป้องกันเพื่อป้องกันการเรียกใช้ API เมื่อ API ไม่พร้อมใช้งาน โค้ดที่เหลือจะทำงานได้ และคุณสามารถเรียกใช้ API ได้ตามปกติโดยไม่ต้องใช้ dlopen() และ dlsym()

การอ้างอิง API แบบไม่สมบูรณ์ไม่จําเป็นต้องได้รับการสนับสนุนเพิ่มเติมจากตัวลิงก์แบบไดนามิก จึงสามารถใช้กับ Android ทุกเวอร์ชันได้

การเปิดใช้การอ้างอิง API ที่อ่อนแอในบิลด์

CMake

ส่ง -DANDROID_WEAK_API_DEFS=ON เมื่อเรียกใช้ CMake หากคุณใช้ CMake ผ่าน externalNativeBuild ให้เพิ่มข้อมูลต่อไปนี้ลงใน build.gradle.kts (หรือเทียบเท่า Groovy หากคุณยังใช้ build.gradle อยู่)

android {
    // Other config...

    defaultConfig {
        // Other config...

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

ndk-build

เพิ่มโค้ดต่อไปนี้ลงในไฟล์ Application.mk

APP_WEAK_API_DEFS := true

หากยังไม่มีไฟล์ Application.mk ให้สร้างไฟล์ดังกล่าวในไดเรกทอรีเดียวกับไฟล์ Android.mk ndk-build ไม่จำเป็นต้องมีการเปลี่ยนแปลงเพิ่มเติมในไฟล์ build.gradle.kts (หรือ build.gradle)

ระบบบิลด์อื่นๆ

หากคุณไม่ได้ใช้ CMake หรือ ndk-build โปรดดูเอกสารประกอบสำหรับระบบการสร้างของคุณเพื่อดูว่ามีวิธีแนะนำในการเปิดใช้ฟีเจอร์นี้หรือไม่ หากระบบบิลด์ของคุณไม่รองรับตัวเลือกนี้โดยค่าเริ่มต้น คุณสามารถเปิดใช้ฟีเจอร์ได้โดยส่ง Flag ต่อไปนี้เมื่อคอมไพล์

-D__ANDROID_UNAVAILABLE_SYMBOLS_ARE_WEAK__ -Werror=unguarded-availability

รายการแรกจะกําหนดค่าส่วนหัว NDK เพื่ออนุญาตการอ้างอิงที่ไม่รัดกุม ส่วนการดําเนินการครั้งที่ 2 จะเปลี่ยนคําเตือนสําหรับการเรียก API ที่ไม่ปลอดภัยให้เป็นข้อผิดพลาด

ดูข้อมูลเพิ่มเติมได้ที่คู่มือผู้ดูแลระบบการบิลด์

การเรียก API ที่มีการป้องกัน

ฟีเจอร์นี้ไม่ได้ทำให้การเรียก API ใหม่ปลอดภัยโดยอัตโนมัติ การดำเนินการเพียงอย่างเดียวที่ทำได้คือเลื่อนข้อผิดพลาดของเวลาในการโหลดไปเป็นข้อผิดพลาดของเวลาในการเรียกใช้ ข้อดีคือคุณสามารถป้องกันคอลนั้นที่รันไทม์และเปลี่ยนไปใช้วิธีอื่นได้ ไม่ว่าจะเป็นการใช้การติดตั้งใช้งานทางเลือกหรือการแจ้งให้ผู้ใช้ทราบว่าฟีเจอร์ของแอปนั้นไม่พร้อมใช้งานในอุปกรณ์ของผู้ใช้ หรือหลีกเลี่ยงเส้นทางโค้ดนั้นไปเลย

Clang อาจแสดงคำเตือน (unguarded-availability) เมื่อคุณเรียกใช้ API ที่ไม่มีให้ใช้งานสำหรับ minSdkVersion ของแอปโดยไม่มีการป้องกัน หากคุณใช้ ndk-build หรือไฟล์เครื่องมือ CMake ของเรา ระบบจะเปิดใช้คำเตือนนั้นโดยอัตโนมัติและเปลี่ยนเป็นข้อผิดพลาดเมื่อเปิดใช้ฟีเจอร์นี้

ต่อไปนี้คือตัวอย่างโค้ดบางส่วนที่ใช้ API แบบมีเงื่อนไขโดยไม่ได้เปิดใช้ฟีเจอร์นี้ โดยใช้ 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);
    }
}

โค้ดนี้อ่านยากเล็กน้อยเนื่องจากมีชื่อฟังก์ชันซ้ำกัน (และหากเขียน C ก็จะมีลายเซ็นซ้ำด้วย) แต่จะคอมไพล์ได้สําเร็จ แต่จะใช้การสำรองเสมอเมื่อรันไทม์หากคุณพิมพ์ชื่อฟังก์ชันที่ส่งไปยัง dlsym ผิดโดยไม่ตั้งใจ และคุณต้องใช้รูปแบบนี้กับ API ทุกรายการ

เมื่อใช้การอ้างอิง API ที่อ่อนแอ ฟังก์ชันด้านบนจะเขียนใหม่ได้ดังนี้

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

เบื้องหลัง __builtin_available(android 31, *) จะเรียกใช้ android_get_device_api_level() แคชผลลัพธ์ และเปรียบเทียบกับ 31 (ซึ่งเป็นระดับ API ที่เปิดตัว AImageDecoder_resultToString())

วิธีที่ง่ายที่สุดในการกำหนดค่าที่จะใช้สำหรับ __builtin_available คือพยายามสร้างโดยไม่ใช้เงื่อนไข (หรือเงื่อนไขของ __builtin_available(android 1, *)) แล้วทำตามข้อความแสดงข้อผิดพลาด เช่น การเรียกใช้ AImageDecoder_createFromAAsset() แบบไม่มีการป้องกันด้วย minSdkVersion 24 จะแสดงผลเป็น

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

ในกรณีนี้ การโทรควรได้รับการปกป้องโดย __builtin_available(android 30, *) หากไม่มีข้อผิดพลาดในการสร้าง แสดงว่า API พร้อมใช้งานสำหรับ minSdkVersion ของคุณเสมอและไม่จำเป็นต้องมีการป้องกัน หรือการสร้างของคุณได้รับการกําหนดค่าไม่ถูกต้องและปิดใช้คําเตือน unguarded-availability

หรือเอกสารอ้างอิง NDK API จะแสดงข้อความว่า "เปิดตัวใน API 30" สำหรับแต่ละ API หากไม่มีข้อความดังกล่าว แสดงว่า API พร้อมใช้งานสำหรับระดับ API ที่รองรับทั้งหมด

การหลีกเลี่ยงการใช้ตัวป้องกัน API ซ้ำ

หากใช้รูปแบบนี้ คุณอาจมีส่วนของโค้ดในแอปที่ใช้ได้เฉพาะในอุปกรณ์ที่ใหม่พอ คุณสามารถกำกับเนื้อหาโค้ดของคุณเองว่าต้องใช้ API ระดับหนึ่งๆ แทนที่จะทำการตรวจสอบ__builtin_available()ซ้ำในแต่ละฟังก์ชัน ตัวอย่างเช่น มีการเพิ่ม API ของ ImageDecoder ไว้ใน API เวอร์ชัน 30 ดังนั้นสําหรับฟังก์ชันที่ใช้ API เหล่านั้นอย่างหนัก คุณอาจทําสิ่งต่อไปนี้ได้

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

ลักษณะเฉพาะของการป้องกัน API

Clang มีข้อกำหนดเฉพาะมากเกี่ยวกับวิธีใช้ __builtin_available เฉพาะ if (__builtin_available(...)) ที่ตรงตามตัวอักษรเท่านั้น (แม้ว่าจะมีการแทนที่ด้วยมาโคร) แม้แต่การดำเนินการง่ายๆ อย่าง if (!__builtin_available(...)) ก็ไม่ทำงาน (Clang จะแสดงคําเตือน unsupported-availability-guard และ unguarded-availability ด้วย) ปัญหานี้อาจได้รับการปรับปรุงใน Clang เวอร์ชันในอนาคต ดูข้อมูลเพิ่มเติมได้ที่ข้อบกพร่อง LLVM 33161

การตรวจสอบ unguarded-availability จะมีผลกับขอบเขตฟังก์ชันที่ใช้เท่านั้น Clang จะแสดงคำเตือนแม้ว่าฟังก์ชันที่มีการเรียก API จะเรียกใช้จากภายในขอบเขตที่มีการป้องกันเท่านั้น หากต้องการหลีกเลี่ยงการใช้เงื่อนไขเริ่มต้นซ้ำในโค้ดของคุณเอง โปรดดูการหลีกเลี่ยงการใช้เงื่อนไขเริ่มต้นของ API ซ้ำ

เหตุใดการตั้งค่านี้จึงไม่เป็นค่าเริ่มต้น

ความแตกต่างระหว่างการอ้างอิง API ที่เข้มงวดกับการอ้างอิง API ที่อ่อนแอคือ การอ้างอิง API ที่เข้มงวดจะดำเนินการไม่สำเร็จอย่างรวดเร็วและเห็นได้ชัด ส่วนการอ้างอิง API ที่อ่อนแอจะไม่ดำเนินการไม่สำเร็จจนกว่าผู้ใช้จะดำเนินการที่ทําให้เรียก API ที่ขาดหายไป เมื่อเกิดกรณีนี้ ข้อความแสดงข้อผิดพลาดจะไม่ชัดเจน แต่จะแสดงเป็นข้อผิดพลาด "AFoo_bar() ไม่พร้อมใช้งาน" ที่เกิดขึ้นขณะคอมไพล์ เมื่อใช้การอ้างอิงที่เข้มงวด ข้อความแสดงข้อผิดพลาดจะชัดเจนขึ้นมาก และการรายงานข้อผิดพลาดอย่างรวดเร็วจะเป็นค่าเริ่มต้นที่ปลอดภัยกว่า

เนื่องจากเป็นฟีเจอร์ใหม่ จึงมีการเขียนโค้ดที่มีอยู่เพียงเล็กน้อยเพื่อจัดการลักษณะการทำงานนี้อย่างปลอดภัย โค้ดของบุคคลที่สามที่ไม่ได้เขียนขึ้นโดยคำนึงถึง Android มักจะมีปัญหานี้อยู่เสมอ ดังนั้นขณะนี้จึงไม่มีแผนที่จะเปลี่ยนแปลงลักษณะการทำงานเริ่มต้น

เราขอแนะนําให้คุณใช้วิธีนี้ แต่เนื่องจากจะทำให้ตรวจหาและแก้ไขข้อบกพร่องได้ยากขึ้น คุณจึงควรยอมรับความเสี่ยงเหล่านั้นอย่างมีสติ ดีกว่าปล่อยให้ลักษณะการทํางานเปลี่ยนแปลงไปโดยที่คุณไม่รู้ตัว

ข้อจำกัด

ฟีเจอร์นี้ใช้ได้กับ API ส่วนใหญ่ แต่ก็มีบางกรณีที่ใช้ไม่ได้

API ของ libc เวอร์ชันใหม่มีแนวโน้มที่จะเกิดปัญหาน้อยที่สุด ซึ่งต่างจาก API อื่นๆ ของ Android เนื่องจาก API เหล่านี้ได้รับการปกป้องด้วย #if __ANDROID_API__ >= X ในส่วนหัว ไม่ใช่แค่ __INTRODUCED_IN(X) ซึ่งจะป้องกันไม่ให้ผู้อื่นเห็นแม้กระทั่งการประกาศที่มีสิทธิ์เข้าถึงระดับต่ำ เนื่องจาก NDK สมัยใหม่รองรับ API ระดับเก่าสุดที่ r21 คุณจึงใช้ libc API ที่จําเป็นมากที่สุดได้อยู่แล้ว ระบบจะเพิ่ม libc API ใหม่ในแต่ละรุ่น (ดูstatus.md) แต่ยิ่ง API ใหม่มากเท่าใด ก็ยิ่งมีแนวโน้มที่จะเป็นแบบ Edge Case ที่นักพัฒนาแอปเพียงไม่กี่รายเท่านั้นที่จะต้องใช้ อย่างไรก็ตาม หากคุณเป็นหนึ่งในนักพัฒนาซอฟต์แวร์เหล่านั้น ขณะนี้คุณจะต้องยังคงใช้ dlsym() เพื่อเรียกใช้ API เหล่านั้นต่อไปหาก minSdkVersion ของคุณเก่ากว่า API ปัญหานี้แก้ไขได้ แต่การแก้ไขอาจทำให้แอปทั้งหมดใช้งานแหล่งที่มาร่วมกันไม่ได้ (โค้ดที่มี polyfill ของ libc API จะคอมไพล์ไม่สำเร็จเนื่องจากแอตทริบิวต์ availability ใน libc และการประกาศในเครื่องไม่ตรงกัน) เราจึงไม่แน่ใจว่าจะแก้ไขหรือไม่และเมื่อใด

กรณีที่นักพัฒนาแอปจำนวนมากอาจพบคือเมื่อไลบรารีที่มี API ใหม่เป็นเวอร์ชันใหม่กว่า minSdkVersion ฟีเจอร์นี้เปิดใช้การอ้างอิงสัญลักษณ์แบบไม่บังคับเท่านั้น ไม่มีการอ้างอิงไลบรารีแบบไม่บังคับ ตัวอย่างเช่น หาก minSdkVersion เป็น 24 คุณจะลิงก์ libvulkan.so และทำการเรียก vkBindBufferMemory2 แบบมีการป้องกันได้ เนื่องจาก libvulkan.so พร้อมใช้งานในอุปกรณ์ที่ใช้ API ตั้งแต่ 24 ขึ้นไป ในทางกลับกัน หาก minSdkVersion เป็น 23 คุณต้องเปลี่ยนไปใช้ dlopen และ dlsym เนื่องจากคลังจะไม่อยู่ในอุปกรณ์ที่รองรับ API 23 เท่านั้น เราไม่ทราบวิธีแก้ปัญหาที่ดีสำหรับเคสนี้ แต่ในระยะยาวปัญหานี้จะแก้ไขได้เองเนื่องจากเราไม่อนุญาตให้ API ใหม่สร้างคลังใหม่อีกต่อไป (เมื่อเป็นไปได้)

สำหรับผู้แต่งคลัง

หากคุณกำลังพัฒนาไลบรารีเพื่อใช้ในแอปพลิเคชัน Android คุณควรหลีกเลี่ยงการใช้ฟีเจอร์นี้ในส่วนหัวแบบสาธารณะ คุณใช้ __builtin_available ในโค้ดนอกบรรทัดได้อย่างปลอดภัย แต่หากใช้ __builtin_available ในโค้ดส่วนหัว เช่น ฟังก์ชันในบรรทัดหรือคําจํากัดความของเทมเพลต คุณจะบังคับให้ผู้ใช้ทุกคนเปิดใช้ฟีเจอร์นี้ คุณควรหลีกเลี่ยงการเลือกตัวเลือกดังกล่าวในนามของผู้บริโภคด้วยเหตุผลเดียวกันกับที่เราไม่ได้เปิดใช้ฟีเจอร์นี้โดยค่าเริ่มต้นใน NDK

หากคุณกำหนดให้ใช้ลักษณะการทำงานนี้ในส่วนหัวแบบสาธารณะ โปรดบันทึกไว้เพื่อให้ผู้ใช้ทราบว่าจะต้องเปิดใช้ฟีเจอร์นี้และตระหนักถึงความเสี่ยงในการดำเนินการดังกล่าว