Рекомендации по кэшированию на стороне клиента Android API

Вызовы API в Android обычно сопряжены со значительной задержкой и большими вычислительными затратами на каждый вызов. Поэтому кэширование на стороне клиента является важным фактором при проектировании API, которые были бы полезными, корректными и производительными.

Мотивация

API, предоставляемые разработчикам приложений в Android SDK, часто реализуются в виде клиентского кода в Android Framework, который выполняет вызов Binder IPC к системной службе в процессе платформы, задача которой — выполнить некоторые вычисления и вернуть результат клиенту. Задержка этой операции обычно определяется тремя основными факторами:

  • Накладные расходы межпроцессного взаимодействия: задержка при выполнении базового вызова межпроцессного взаимодействия обычно в 10 000 раз выше, чем при выполнении базового вызова метода в рамках одного процесса.
  • Конфликт на стороне сервера: работа, выполняемая в системной службе в ответ на запрос клиента, может начаться не сразу, например, если поток сервера занят обработкой других запросов, поступивших ранее.
  • Вычисления на стороне сервера: сама обработка запроса на сервере может потребовать значительных трудозатрат.

Все три фактора задержки можно устранить, внедрив кэширование на стороне клиента, при условии, что кэш соответствует следующим требованиям:

  • Верно: кэш на стороне клиента никогда не возвращает результаты, которые отличались бы от тех, что вернул бы сервер.
  • Эффективность: запросы клиентов часто обрабатываются из кэша, например, кэш имеет высокий процент попаданий в него.
  • Эффективность: кэш на стороне клиента эффективно использует ресурсы клиента, например, представляя кэшированные данные в компактном виде и не храня слишком много кэшированных результатов или устаревших данных в памяти клиента.

Рассмотрите возможность кэширования результатов сервера на стороне клиента.

Если клиенты часто отправляют один и тот же запрос несколько раз, и возвращаемое значение со временем не меняется, то следует реализовать кэш в клиентской библиотеке, ключом которого являются параметры запроса.

Рассмотрите возможность использования 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 .

Сбрасывать кэш при изменениях на стороне сервера.

Если значение, возвращаемое сервером, может меняться со временем, реализуйте функцию обратного вызова для отслеживания изменений и зарегистрируйте такую ​​функцию, чтобы при необходимости можно было соответствующим образом аннулировать кэш на стороне клиента.

Аннулировать кэш между модульными тестами.

В наборе модульных тестов вы можете тестировать клиентский код на тестовом двойнике, а не на реальном сервере. В этом случае обязательно очищайте кэш на стороне клиента между тестовыми случаями. Это необходимо для обеспечения взаимной герметичности тестовых случаев и предотвращения конфликта между ними.

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

Поля

Хиты:

  • Определение: Количество раз, когда запрошенный фрагмент данных был успешно найден в кэше.
  • Significance: Indicates an efficient and fast retrieval of data, reducing unnecessary data retrieval.
  • Чем выше количество, тем, как правило, лучше.

Очищает:

  • Определение: Количество раз, когда кэш был очищен из-за аннулирования.
  • Причины для одобрения:
    • Аннулирование: устаревшие данные с сервера.
    • Управление пространством: освобождение места для новых данных, когда кэш заполнен.
  • Высокие значения могут указывать на частые изменения данных и потенциальную неэффективность.

Промахи:

  • Определение: Количество случаев, когда кэш не смог предоставить запрошенные данные.
  • Причины:
    • Неэффективное кэширование: слишком маленький размер кэша или кэш не хранит нужные данные.
    • Данные часто меняются.
    • Запросы поступают впервые.
  • Высокие значения указывают на потенциальные проблемы с кэшированием.

Пропуски:

  • Определение: Случаи, когда кэш вообще не использовался, хотя мог бы.
  • Причины пропуска:
    • Corking: Эта функция, специфичная для обновлений Android Package Manager, намеренно отключает кэширование из-за большого количества вызовов во время загрузки.
    • Не задано: Кэш существует, но не инициализирован. Значение nonce не было задано, что означает, что кэш никогда не был аннулирован.
    • Обход: Преднамеренное решение пропустить кэш.
  • Высокие значения указывают на потенциальную неэффективность использования кэша.

Аннулирует:

  • Определение: Процесс пометки кэшированных данных как устаревших или неактуальных.
  • Значение: Показывает, что система работает с самыми актуальными данными, предотвращая ошибки и несоответствия.
  • Обычно это происходит по требованию сервера, которому принадлежат данные.

Текущий размер:

  • Определение: Текущее количество элементов в кэше.
  • Значение: Указывает на использование ресурсов кэша и потенциальное влияние на производительность системы.
  • Более высокие значения обычно означают, что кэш использует больше памяти.

Максимальный размер:

  • Определение: Максимальный объем пространства, выделяемый для кэша.
  • Значение: Определяет емкость кэша и его способность хранить данные.
  • Установка соответствующего максимального размера помогает сбалансировать эффективность кэширования с использованием памяти. После достижения максимального размера добавляется новый элемент путем удаления наименее недавно использованного элемента, что может указывать на неэффективность.

High Water Mark:

  • Определение: Максимальный размер, достигнутый кэшем с момента его создания.
  • Значение: Предоставляет информацию о пиковом использовании кэша и потенциальной нагрузке на память.
  • Мониторинг уровня максимальной воды может помочь выявить потенциальные узкие места или области для оптимизации.

Переполнения:

  • Определение: Количество раз, когда размер кэша превышал максимально допустимый, и приходилось удалять данные, чтобы освободить место для новых записей.
  • Значимость: указывает на перегрузку кэша и потенциальное снижение производительности из-за вытеснения данных.
  • Большое количество переполнений указывает на необходимость корректировки размера кэша или пересмотра стратегии кэширования.

Аналогичные статистические данные можно найти и в отчете об ошибке.

Настройте размер кэша.

У кэша есть максимальный размер. При превышении максимального размера кэша записи удаляются в порядке LRU (Low to Resource — последний, последний — последний).

  • Кэширование слишком малого количества записей может негативно повлиять на коэффициент попадания в кэш.
  • Кэширование слишком большого количества записей увеличивает объем памяти, используемой кэшем.

Найдите оптимальный баланс для вашего конкретного случая.

Исключите лишние звонки клиентам.

Клиенты могут отправлять один и тот же запрос на сервер несколько раз за короткий промежуток времени:

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 Гц. Но гипотетически, клиентский код в 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 .

Удаление дубликатов серверных обратных вызовов в клиенте.

Lastly, the API client may register callbacks with the API server to be notified of events.

Для приложений обычно характерно регистрировать несколько обратных вызовов для одной и той же базовой информации. Вместо того чтобы сервер уведомлял клиента один раз о каждом зарегистрированном обратном вызове с использованием межпроцессного взаимодействия (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"];
  }
}