שימוש בממשקי API חדשים יותר

בדף הזה נסביר איך האפליקציה שלכם יכולה להשתמש בפונקציות חדשות של מערכת ההפעלה כשהיא פועלת בגרסאות חדשות של מערכת ההפעלה, תוך שמירה על תאימות למכשירים ישנים יותר.

כברירת מחדל, הפניות לממשקי API של NDK באפליקציה הן הפניות חזקות. מעבד ההטענה הדינמי של Android יטפל בהן בזמן טעינת הספרייה. אם הסמלים לא יימצאו, האפליקציה תבוטל. זה בניגוד לאופן שבו Java פועלת, שם לא תופיע חריגה עד שמפעילים את ממשק ה-API החסר.

לכן, NDK ימנע מכם ליצור הפניות חזקות לממשקי API חדשים יותר מ-minSdkVersion של האפליקציה. כך תוכלו למנוע שליחה בטעות של קוד שפעל במהלך הבדיקה, אבל לא ייטען (UnsatisfiedLinkError יושלח מ-System.loadLibrary()) במכשירים ישנים יותר. מצד שני, קשה יותר לכתוב קוד שמשתמש בממשקי API חדשים יותר מ-minSdkVersion של האפליקציה, כי צריך לקרוא לממשקי ה-API באמצעות dlopen() ו-dlsym() במקום קריאה רגילה לפונקציה.

האלטרנטיבה לשימוש בהפניות חזקות היא שימוש בהפניות חלשות. אם לא נמצא הפניה חלשה כשהספרייה נטענת, הכתובת של הסמל הזה מוגדרת ל-nullptr במקום שהטעינה תיכשל. עדיין אי אפשר לבצע קריאה בטוחה אליהן, אבל כל עוד אתרי הקריאה מוגנים כדי למנוע קריאה ל-API כשהוא לא זמין, אפשר להריץ את שאר הקוד ולקרוא ל-API כרגיל בלי להשתמש ב-dlopen() וב-dlsym().

לא צריך תמיכה נוספת מהמקשר הדינמי כדי להשתמש בהפניות חלשות ל-API, ולכן אפשר להשתמש בהן בכל גרסה של Android.

הפעלת הפניות חלשות ל-API ב-build

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. אין צורך לבצע שינויים נוספים בקובץ build.gradle.kts (או build.gradle) כדי להשתמש ב-ndk-build.

מערכות build אחרות

אם אתם לא משתמשים ב-CMake או ב-ndk-build, כדאי לעיין במסמכי העזרה של מערכת ה-build כדי לבדוק אם יש דרך מומלצת להפעיל את התכונה הזו. אם מערכת ה-build שלכם לא תומכת באפשרות הזו באופן מקורי, תוכלו להפעיל את התכונה על ידי העברת הדגלים הבאים במהלך הידור:

-D__ANDROID_UNAVAILABLE_SYMBOLS_ARE_WEAK__ -Werror=unguarded-availability

ההגדרה הראשונה מגדירה את כותרות ה-NDK כך שיאפשרו הפניות חלשות. השני הופך את האזהרה על קריאות API לא בטוחות לשגיאה.

מידע נוסף זמין במדריך למנהלי מערכת ה-build.

קריאות API מוגנות

התכונה הזו לא הופכת באופן קסום את הקריאות לממשקי API חדשים לבטוחות. כל מה שהוא עושה הוא לדחות שגיאה בזמן הטעינה לשגיאה בזמן הקריאה. היתרון הוא שתוכלו להגן על הקריאה הזו בזמן הריצה ולבצע חזרה יפה (graceful fallback), בין שבאמצעות הטמעה חלופית, בין שבאמצעות הודעה למשתמש שהתכונה הזו של האפליקציה לא זמינה במכשיר שלו, ובין שבאמצעות הימנעות מנתיב הקוד הזה לגמרי.

Clang יכול להנפיק אזהרה (unguarded-availability) כשמבצעים קריאה לא מאובטחת ל-API שלא זמין ל-minSdkVersion של האפליקציה. אם אתם משתמשים ב-ndk-build או בקובץ של CMake toolchain, האזהרה הזו תופעל באופן אוטומטי ותשודר כשגיאה כשתפעילו את התכונה הזו.

לפניכם דוגמה לקוד שמשתמש ב-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, *). אם אין שגיאה ב-build, המשמעות היא שה-API תמיד זמין ל-minSdkVersion ואין צורך במחסום, או שה-build מוגדר באופן שגוי והאזהרה unguarded-availability מושבתת.

לחלופין, בהפניה ל-NDK API תופיע הערה כמו 'Introduced in API 30' (הושק ב-API 30) לכל ממשק API. אם הטקסט הזה לא מופיע, המשמעות היא שה-API זמין לכל רמות ה-API הנתמכות.

הימנעות מחזרות של מאגרי API

אם משתמשים באפשרות הזו, סביר להניח שבאפליקציה יהיו קטעי קוד שאפשר להשתמש בהם רק במכשירים חדשים מספיק. במקום לחזור על הבדיקה __builtin_available() בכל אחת מהפונקציות, אפשר להוסיף הערה לקוד שלכם ולציין שרמת ה-API הנדרשת היא רמה מסוימת. לדוגמה, ממשקי ה-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. מידע נוסף זמין בבעיה 33161 ב-LLVM.

הבדיקות של unguarded-availability חלות רק על היקף הפונקציה שבו הן נמצאות בשימוש. Clang יפיק את האזהרה גם אם הפונקציה עם קריאת ה-API תמיד תיקרא רק מתוך היקף מוגן. כדי להימנע מהצגה חוזרת של אמצעי הבקרה בקוד שלכם, תוכלו לעיין במאמר הימנעות מהצגה חוזרת של אמצעי בקרה ל-API.

למה זה לא ברירת המחדל?

אם לא משתמשים בהן בצורה נכונה, ההבדל בין הפניות חזקות ל-API לבין הפניות חלשות ל-API הוא שהראשונות ייכשל במהירות ובאופן ברור, ואילו האחרונות לא ייכשל עד שהמשתמש יבצע פעולה שגורמת לקריאה לממשק ה-API החסר. במקרה כזה, הודעת השגיאה לא תהיה שגיאה ברורה בזמן הידור מסוג ‎ "AFoo_bar() is not available"‎, אלא segfault. כשמשתמשים בהפניות חזקות, הודעת השגיאה ברורה הרבה יותר, ו-failing-fast הוא ברירת מחדל בטוחה יותר.

מכיוון שזו תכונה חדשה, יש מעט מאוד קוד קיים שנכתב כדי לטפל בהתנהגות הזו בצורה בטוחה. סביר להניח שקוד של צד שלישי שלא נכתב בהתאם ל-Android תמיד יגרום לבעיה הזו, ולכן אין כרגע תוכניות לשינוי התנהגות ברירת המחדל.

אנחנו כן ממליצים להשתמש באפשרות הזו, אבל מכיוון שקשה יותר לזהות ולפתור בעיות כשההתנהגות משתנה, כדאי להבין את הסיכונים האלה ולהסכים להשתמש בהם במקום שההתנהגות תשתנה ללא ידיעתכם.

נקודות שצריך לשים לב אליהן:

התכונה הזו פועלת ברוב ממשקי ה-API, אבל יש כמה מקרים שבהם היא לא פועלת.

ממשקי ה-API החדשים של libc הם הכי פחות צפויים לגרום לבעיות. בניגוד לשאר ממשקי ה-API של Android, הם מוגנים באמצעות #if __ANDROID_API__ >= X בכותרות ולא רק באמצעות __INTRODUCED_IN(X), וכך גם ההצהרה החלשה לא מוצגת. רמת ה-API הישנה ביותר שנתמכת ב-NDKs המודרניים היא r21, ולכן כבר זמינים ממשקי ה-API הנפוצים ביותר של libc. ממשקי API חדשים של libc מתווספים בכל מהדורה (ראו status.md), אבל ככל שהם חדשים יותר, כך סביר יותר שהם יהיו מקרים קיצוניים שבהם רק מעט מפתחים יצטרכו להשתמש. עם זאת, אם אתם מפתחים כאלה, בשלב הזה תצטרכו להמשיך להשתמש ב-dlsym() כדי לבצע קריאות ל-API האלה אם minSdkVersion שלכם ישן יותר מה-API. זו בעיה שניתן לפתור, אבל הפעולה הזו עלולה לגרום לשבירת תאימות המקור לכל האפליקציות (כל קוד שמכיל polyfills של ממשקי API של libc לא יתבצע קומפילציה בגלל אי-התאמה של מאפייני availability ב-libc ובהצהרות המקומיות), ולכן אנחנו לא בטוחים אם נתקן את הבעיה או מתי נתקן אותה.

הסיטואציה שבה סביר להניח שמפתחים רבים יותר ייתקלו היא כאשר הספרייה שמכילה את ה-API החדש חדשה יותר מ-minSdkVersion. התכונה הזו מאפשרת רק הפניות לסמלים חלשים. אין דבר כזה 'הפניה לספרייה חלשה'. לדוגמה, אם הערך של minSdkVersion הוא 24, אפשר לקשר את libvulkan.so ולבצע קריאה מאובטחת ל-vkBindBufferMemory2, כי libvulkan.so זמין במכשירים שמתחילים ב-API 24. לעומת זאת, אם הערך של minSdkVersion היה 23, תצטרכו להשתמש ב-dlopen וב-dlsym כי הספרייה לא תהיה במכשיר במכשירים שתומכים רק ב-API 23. אין לנו פתרון טוב לבעיה הזו, אבל בטווח הארוך היא תיפתר מעצמה כי אנחנו לא מאפשרים יותר (בכל מקרה אפשרי) ל-API חדשים ליצור ספריות חדשות.

למחברים של ספריות

אם אתם מפתחים ספרייה לשימוש באפליקציות ל-Android, כדאי להימנע משימוש בתכונה הזו בכותרות הציבוריות. אפשר להשתמש בו בבטחה בקוד מחוץ לשורה, אבל אם תסתמכו על __builtin_available בקוד כלשהו בכותרות, כמו פונקציות מוטמעות או הגדרות של תבניות, תאלצו את כל הצרכנים להפעיל את התכונה הזו. מאותה סיבה שאנחנו לא מפעילים את התכונה הזו כברירת מחדל ב-NDK, כדאי להימנע מקבלת ההחלטה הזו בשם הצרכנים שלכם.

אם אתם כן דורשים את ההתנהגות הזו בכותרות הציבוריות, חשוב לתעד זאת כדי שהמשתמשים ידעו שהם יצטרכו להפעיל את התכונה ויהיו מודעים לסיכונים הכרוכים בכך.