הנחיות לגבי שמירת נתונים במטמון בצד הלקוח של Android API

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

למה בחרנו לעשות זאת?

ממשקי API שמוצגים למפתחי אפליקציות ב-Android SDK מיושמים לעיתים קרובות כקוד לקוח ב-Android Framework שמבצע קריאת Binder IPC לשירות מערכת בתהליך פלטפורמה, שתפקידו לבצע חישוב ולהחזיר תוצאה ללקוח. זמן האחזור של הפעולה הזו מושפע בדרך כלל משלושה גורמים:

  • תקורה של IPC: זמן האחזור של קריאת IPC בסיסית הוא בדרך כלל פי 10,000 מזמן האחזור של הפעלת method בסיסית בתוך תהליך.
  • התנגשות בצד השרת: יכול להיות שהעבודה שמתבצעת בשירות המערכת בתגובה לבקשה של הלקוח לא תתחיל באופן מיידי, למשל אם השרשור של השרת עסוק בטיפול בבקשות אחרות שהתקבלו קודם.
  • חישוב בצד השרת: יכול להיות שהעבודה עצמה לטיפול בבקשה בשרת תדרוש מאמץ משמעותי.

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

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

כדאי לשקול שמירת תוצאות של שרת במטמון בצד הלקוח

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

כדאי להשתמש ב-IpcDataCache בהטמעה:

public class BirthdayManager {
    private final IpcDataCache.QueryHandler<User, Birthday> mBirthdayQuery =
            new IpcDataCache.QueryHandler<User, Birthday>() {
                @Override
                public Birthday apply(User user) {
                    return mService.getBirthday(user);
                }
            };
    private static final int BDAY_CACHE_MAX = 8;  // Maximum birthdays to cache
    private static final String BDAY_API = "getUserBirthday";
    private final IpcDataCache<User, Birthday> mCache
            new IpcDataCache<User, Birthday>(
                BDAY_CACHE_MAX, MODULE_SYSTEM, BDAY_API,  BDAY_API, mBirthdayQuery);

    /** @hide **/
    @VisibleForTesting
    public static void clearCache() {
        IpcDataCache.invalidateCache(MODULE_SYSTEM, BDAY_API);
    }

    public Birthday getBirthday(User user) {
        return mCache.query(user);
    }
}

דוגמה מלאה מופיעה בקטע android.app.admin.DevicePolicyManager.

IpcDataCache זמין לכל קוד המערכת, כולל מודולים מרכזיים. יש גם את PropertyInvalidatedCache שהוא כמעט זהה, אבל הוא גלוי רק למסגרת. מומלץ להשתמש ב-IpcDataCache כשהדבר אפשרי.

ביטול התוקף של מטמונים בשינויים בצד השרת

אם הערך שמוחזר מהשרת יכול להשתנות לאורך זמן, צריך להטמיע קריאה חוזרת (callback) כדי לעקוב אחרי שינויים, ולרשום קריאה חוזרת כדי לבטל את המטמון בצד הלקוח בהתאם.

ביטול תוקף של מטמונים בין מקרי בדיקה של יחידות

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

@RunWith(AndroidJUnit4.class)
public class BirthdayManagerTest {

    @Before
    public void setUp() {
        BirthdayManager.clearCache();
    }

    @After
    public void tearDown() {
        BirthdayManager.clearCache();
    }

    ...
}

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

בדיקת פגיעות במטמון והחטאות במטמון

IpcDataCache ו-PropertyInvalidatedCache יכולים להדפיס נתונים סטטיסטיים בזמן אמת:

adb shell dumpsys cacheinfo
  ...
  Cache Name: cache_key.is_compat_change_enabled
    Property: cache_key.is_compat_change_enabled
    Hits: 1301458, Misses: 21387, Skips: 0, Clears: 39
    Skip-corked: 0, Skip-unset: 0, Skip-bypass: 0, Skip-other: 0
    Nonce: 0x856e911694198091, Invalidates: 72, CorkedInvalidates: 0
    Current Size: 1254, Max Size: 2048, HW Mark: 2049, Overflows: 310
    Enabled: true
  ...

שדות

כניסות:

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

Clears:

  • הגדרה: מספר הפעמים שהמטמון נוקה בגלל ביטול תוקף.
  • הסיבות לניקוי:
    • פסילה: נתונים לא עדכניים מהשרת.
    • ניהול נפח האחסון: פינוי מקום לנתונים חדשים כשהמטמון מלא.
  • מספרים גבוהים יכולים להצביע על נתונים שמשתנים לעיתים קרובות ועל חוסר יעילות פוטנציאלי.

Misses:

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

דילוגים:

  • הגדרה: מקרים שבהם לא נעשה שימוש במטמון בכלל, למרות שאפשר היה להשתמש בו.
  • הסיבות לדילוג:
    • Corking: תופעה שקשורה לעדכונים של Android Package Manager, שבה המערכת משביתה בכוונה את השמירה במטמון בגלל נפח גדול של שיחות במהלך האתחול.
    • לא מוגדר: המטמון קיים אבל לא אותחל. הערך של nonce לא הוגדר, מה שאומר שהמטמון מעולם לא בוטל.
    • עקיפה: החלטה מכוונת לדלג על המטמון.
  • מספרים גבוהים מצביעים על חוסר יעילות פוטנציאלי בשימוש במטמון.

תאריך פקיעת התוקף:

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

הגודל הנוכחי:

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

גודל מקסימלי:

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

High Water Mark:

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

הצגת יתר של מודעות:

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

אפשר למצוא את אותם נתונים סטטיסטיים גם בדוח על באג.

שינוי הגודל של המטמון

למטמון יש גודל מקסימלי. כשחורגים מהגודל המקסימלי של המטמון, המערכת מסירה את הרשומות לפי סדר LRU.

  • שמירת מספר קטן מדי של רשומות במטמון עלולה להשפיע לרעה על שיעור הפגיעה במטמון.
  • שמירה במטמון של יותר מדי רשומות מגדילה את השימוש בזיכרון של המטמון.

חשוב למצוא את האיזון הנכון לתרחיש השימוש שלכם.

הסרת קריאות מיותרות ללקוח

יכול להיות שהלקוחות ישלחו את אותה שאילתה לשרת כמה פעמים בפרק זמן קצר:

public void executeAll(List<Operation> operations) throws SecurityException {
    for (Operation op : operations) {
        for (Permission permission : op.requiredPermissions()) {
            if (!permissionChecker.checkPermission(permission, ...)) {
                throw new SecurityException("Missing permission " + permission);
            }
        }
        op.execute();
  }
}

כדאי להשתמש מחדש בתוצאות משיחות קודמות:

public void executeAll(List<Operation> operations) throws SecurityException {
    Set<Permission> permissionsChecked = new HashSet<>();
    for (Operation op : operations) {
        for (Permission permission : op.requiredPermissions()) {
            if (!permissionsChecked.add(permission)) {
                if (!permissionChecker.checkPermission(permission, ...)) {
                    throw new SecurityException(
                            "Missing permission " + permission);
                }
            }
        }
        op.execute();
  }
}

כדאי לשקול שימוש בממויזציה בצד הלקוח של תגובות שרת עדכניות

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

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

@UiThread
private void setStats() {
    mobileRxBytesTextView.setText(
        Long.toString(TrafficStats.getMobileRxBytes()));
    mobileRxPacketsTextView.setText(
        Long.toString(TrafficStats.getMobileRxPackages()));
    mobileTxBytesTextView.setText(
        Long.toString(TrafficStats.getMobileTxBytes()));
    mobileTxPacketsTextView.setText(
        Long.toString(TrafficStats.getMobileTxPackages()));
}

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

participant App code as app
participant Client library as clib
participant Server as server

app->clib: request @ T=100ms
clib->server: request
server->clib: response 1
clib->app: response 1

app->clib: request @ T=200ms
clib->app: response 1

app->clib: request @ T=300ms
clib->app: response 1

app->clib: request @ T=2000ms
clib->server: request
server->clib: response 2
clib->app: response 2

מומלץ להשתמש ביצירת קוד בצד הלקוח במקום בשאילתות בצד השרת

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

כדאי לעיין בקוד האפליקציה הבא שבודק אם המכשיר הוא שעון (כלומר, אם במכשיר פועלת מערכת Wear OS):

public boolean isWatch(Context ctx) {
    PackageManager pm = ctx.getPackageManager();
    return pm.hasSystemFeature(PackageManager.FEATURE_WATCH);
}

המאפיין הזה של המכשיר ידוע בזמן הבנייה, במיוחד בזמן שבו ה-Framework נבנה עבור תמונת האתחול של המכשיר. הקוד בצד הלקוח של hasSystemFeature יכול להחזיר תוצאה מוכרת באופן מיידי, במקום לשלוח שאילתה לשירות המערכת המרוחק PackageManager.

ביטול כפילויות של קריאות חוזרות (callback) לשרת בצד הלקוח

לבסוף, לקוח ה-API יכול לרשום קריאות חוזרות (callback) בשרת ה-API כדי לקבל הודעות על אירועים.

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

digraph d_front_back {
  rankdir=RL;
  node [style=filled, shape="rectangle", fontcolor="white" fontname="Roboto"]
  server->clib
  clib->c1;
  clib->c2;
  clib->c3;

  subgraph cluster_client {
    graph [style="dashed", label="Client app process"];
    c1 [label="my.app.FirstCallback" color="#4285F4"];
    c2 [label="my.app.SecondCallback" color="#4285F4"];
    c3 [label="my.app.ThirdCallback" color="#4285F4"];
    clib [label="android.app.FooManager" color="#F4B400"];
  }

  subgraph cluster_server {
    graph [style="dashed", label="Server process"];
    server [label="com.android.server.FooManagerService" color="#0F9D58"];
  }
}