دستورالعمل های حافظه پنهان سمت سرویس گیرنده Android API

فراخوانی‌های API اندروید معمولاً شامل تأخیر و محاسبات قابل توجهی در هر فراخوانی هستند. بنابراین، ذخیره‌سازی سمت کلاینت یک نکته مهم در طراحی APIهایی است که مفید، صحیح و کارآمد باشند.

انگیزه

رابط‌های برنامه‌نویسی کاربردی (API) که در SDK اندروید در اختیار توسعه‌دهندگان برنامه قرار می‌گیرند، اغلب به عنوان کد کلاینت در چارچوب اندروید پیاده‌سازی می‌شوند که یک فراخوانی IPC بایندر را به یک سرویس سیستمی در یک فرآیند پلتفرم انجام می‌دهد، که وظیفه آن انجام برخی محاسبات و بازگرداندن نتیجه به کلاینت است. تأخیر این عملیات معمولاً تحت تأثیر سه عامل است:

  • سربار IPC: یک فراخوانی IPC پایه معمولاً 10000 برابر تأخیر یک فراخوانی متد پایه در حال انجام دارد.
  • درگیری سمت سرور: کاری که در سرویس سیستم در پاسخ به درخواست کلاینت انجام می‌شود، ممکن است بلافاصله شروع نشود، برای مثال اگر یک نخ سرور مشغول رسیدگی به درخواست‌های دیگری باشد که زودتر رسیده‌اند.
  • محاسبات سمت سرور: خودِ کارِ مدیریت درخواست در سرور ممکن است به کار قابل توجهی نیاز داشته باشد.

شما می‌توانید هر سه عامل تأخیر را با پیاده‌سازی یک حافظه پنهان (cache) در سمت کلاینت از بین ببرید، مشروط بر اینکه حافظه پنهان:

  • درست است: حافظه پنهان سمت کلاینت هرگز نتایجی را که متفاوت از آنچه سرور برمی‌گرداند، برمی‌گرداند.
  • موثر: درخواست‌های کلاینت اغلب از حافظه پنهان (cache) ارائه می‌شوند، برای مثال، حافظه پنهان نرخ موفقیت بالایی دارد.
  • کارآمد: حافظه پنهان سمت کلاینت، از منابع سمت کلاینت به طور کارآمد استفاده می‌کند، مانند نمایش داده‌های ذخیره شده به صورت فشرده و عدم ذخیره بیش از حد نتایج ذخیره شده یا داده‌های قدیمی در حافظه کلاینت.

ذخیره نتایج سرور در کلاینت را در نظر بگیرید

اگر کلاینت‌ها اغلب یک درخواست دقیقاً یکسان را چندین بار ارسال می‌کنند و مقدار برگشتی با گذشت زمان تغییر نمی‌کند، باید یک حافظه پنهان (cache) در کتابخانه کلاینت پیاده‌سازی کنید که با پارامترهای درخواست کلیدگذاری شده باشد.

استفاده از 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 ترجیح دهید.

Invalidate caches on server-side changes

اگر مقدار برگردانده شده از سرور می‌تواند با گذشت زمان تغییر کند، یک فراخوانی برای مشاهده تغییرات پیاده‌سازی کنید و یک فراخوانی ثبت کنید تا بتوانید حافظه پنهان سمت کلاینت را بر این اساس نامعتبر کنید.

نامعتبر کردن حافظه‌های نهان بین موارد تست واحد

در یک مجموعه تست واحد، ممکن است کد کلاینت را به جای سرور واقعی، روی یک تست دابل تست کنید. در این صورت، حتماً هرگونه حافظه پنهان سمت کلاینت را بین موارد تست پاک کنید. این کار برای حفظ یکپارچگی متقابل موارد تست و جلوگیری از تداخل یک مورد تست با دیگری است.

@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
  ...

فیلدها

بازدیدها:

  • تعریف: تعداد دفعاتی که یک قطعه داده درخواستی با موفقیت در حافظه پنهان یافت شده است.
  • اهمیت: نشان‌دهنده بازیابی کارآمد و سریع داده‌ها است و بازیابی داده‌های غیرضروری را کاهش می‌دهد.
  • تعداد بالاتر معمولاً بهتر است.

پاک می‌کند:

  • تعریف: تعداد دفعاتی که حافظه پنهان به دلیل نامعتبر بودن پاک شده است.
  • دلایل پاکسازی:
    • نامعتبر بودن: داده‌های قدیمی از سرور.
    • مدیریت فضا: ایجاد فضا برای داده‌های جدید در صورت پر شدن حافظه پنهان.
  • تعداد بالا می‌تواند نشان‌دهنده‌ی تغییر مکرر داده‌ها و ناکارآمدی بالقوه باشد.

از دست رفته‌ها:

  • تعریف: تعداد دفعاتی که حافظه پنهان نتوانسته داده‌های درخواستی را ارائه دهد.
  • علل:
    • ذخیره‌سازی ناکارآمد: حافظه پنهان (cache) خیلی کوچک است یا داده‌های صحیح را ذخیره نمی‌کند.
    • داده‌هایی که مرتباً تغییر می‌کنند.
    • درخواست‌های اولیه.
  • تعداد بالا نشان دهنده مشکلات احتمالی در حافظه پنهان است.

پرش‌ها:

  • تعریف: مواردی که حافظه پنهان اصلاً استفاده نشده است، حتی اگر می‌توانست استفاده شود.
  • دلایل رد شدن:
    • قفل کردن: مختص به‌روزرسانی‌های Android Package Manager، که به دلیل حجم بالای فراخوانی‌ها در هنگام بوت، عمداً حافظه پنهان را غیرفعال می‌کند.
    • تنظیم نشده: حافظه پنهان وجود دارد اما مقداردهی اولیه نشده است. مقدار nonce تنظیم نشده است، به این معنی که حافظه پنهان هرگز نامعتبر نشده است.
    • بای‌پس: تصمیم عمدی برای نادیده گرفتن کش.
  • تعداد بالا نشان‌دهنده‌ی ناکارآمدی بالقوه در استفاده از حافظه‌ی نهان است.

Invalidates:

  • تعریف: فرآیند علامت‌گذاری داده‌های ذخیره‌شده در حافظه پنهان (cache) به عنوان داده‌های منسوخ یا قدیمی.
  • اهمیت: نشان می‌دهد که سیستم با به‌روزترین داده‌ها کار می‌کند و از خطاها و ناسازگاری‌ها جلوگیری می‌کند.
  • معمولاً توسط سروری که مالک داده‌ها است، فعال می‌شود.

اندازه فعلی:

  • تعریف: تعداد فعلی عناصر موجود در حافظه پنهان.
  • اهمیت: میزان استفاده از منابع حافظه پنهان و تأثیر بالقوه آن بر عملکرد سیستم را نشان می‌دهد.
  • مقادیر بالاتر معمولاً به معنای استفاده بیشتر از حافظه کش است.

حداکثر اندازه:

  • تعریف: حداکثر فضای اختصاص داده شده برای حافظه پنهان.
  • اهمیت: ظرفیت حافظه پنهان و توانایی آن در ذخیره داده‌ها را تعیین می‌کند.
  • تنظیم حداکثر اندازه مناسب به ایجاد تعادل بین کارایی حافظه پنهان و میزان استفاده از حافظه کمک می‌کند. پس از رسیدن به حداکثر اندازه، با حذف عنصری که اخیراً کمتر استفاده شده است، عنصر جدیدی اضافه می‌شود که می‌تواند نشان‌دهنده ناکارآمدی باشد.

علامت آب بالا:

  • تعریف: حداکثر اندازه‌ای که حافظه پنهان از زمان ایجاد به آن رسیده است.
  • اهمیت: بینش‌هایی در مورد اوج استفاده از حافظه پنهان و فشار احتمالی بر حافظه ارائه می‌دهد.
  • نظارت بر علامت بالای آب می‌تواند به شناسایی تنگناهای بالقوه یا حوزه‌های نیازمند بهینه‌سازی کمک کند.

سرریزها:

  • تعریف: تعداد دفعاتی که حافظه پنهان از حداکثر اندازه خود فراتر رفته و مجبور به حذف داده‌ها برای ایجاد فضا برای ورودی‌های جدید شده است.
  • اهمیت: نشان‌دهنده فشار بر حافظه پنهان و افت عملکرد بالقوه به دلیل حذف داده‌ها است.
  • تعداد بالای سرریز نشان می‌دهد که ممکن است اندازه حافظه پنهان نیاز به تنظیم داشته باشد یا استراتژی ذخیره‌سازی مورد ارزیابی مجدد قرار گیرد.

همین آمار را می‌توان در گزارش اشکال نیز یافت.

اندازه حافظه پنهان را تنظیم کنید

حافظه‌های نهان (cache) حداکثر اندازه‌ای دارند. وقتی از حداکثر اندازه حافظه نهان تجاوز شود، ورودی‌ها به ترتیب LRU حذف می‌شوند.

  • ذخیره تعداد بسیار کمی از ورودی‌ها در حافظه پنهان می‌تواند بر میزان موفقیت در حافظه پنهان تأثیر منفی بگذارد.
  • ذخیره سازی تعداد زیادی ورودی در حافظه پنهان (cache)، میزان استفاده از حافظه پنهان (cache) را افزایش می‌دهد.

تعادل مناسب را برای مورد استفاده خود پیدا کنید.

حذف تماس‌های غیرضروری مشتریان

کلاینت‌ها ممکن است در یک بازه زمانی کوتاه، چندین بار یک درخواست مشابه را به سرور ارسال کنند:

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

این برنامه ممکن است فریم‌ها را با سرعت ۶۰ هرتز ترسیم کند. اما به صورت فرضی، کد کلاینت در 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);
}

این ویژگی دستگاه در زمان ساخت، به ویژه در زمانی که چارچوب برای تصویر بوت این دستگاه ساخته شده است، شناخته شده است. کد سمت کلاینت برای hasSystemFeature می‌تواند بلافاصله یک نتیجه شناخته شده را برگرداند، به جای اینکه از سرویس سیستم PackageManager از راه دور پرس و جو کند.

حذف فراخوانی‌های تکراری سرور در کلاینت

در نهایت، کلاینت 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"];
  }
}