Вызовы 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"];
}
}