Вызовы API Android обычно подразумевают значительную задержку и вычисления на вызов. Поэтому кэширование на стороне клиента является важным фактором при проектировании полезных, корректных и производительных API.
Мотивация
API, предоставляемые разработчикам приложений в Android SDK, часто реализуются как клиентский код в Android Framework, который делает вызов Binder IPC к системной службе в процессе платформы, чья работа заключается в выполнении некоторых вычислений и возврате результата клиенту. Задержка этой операции обычно определяется тремя факторами:
- Накладные расходы IPC: задержка базового вызова 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
...
Поля
Хиты:
- Определение: Количество раз, когда запрошенный фрагмент данных был успешно найден в кэше.
- Значимость: указывает на эффективный и быстрый поиск данных, что сокращает ненужный поиск данных.
- Более высокие показатели, как правило, лучше.
Очищает:
- Определение: Количество раз, когда кэш был очищен из-за аннулирования.
- Причины очистки:
- Недействительность: Устаревшие данные на сервере.
- Управление пространством: освобождение места для новых данных при заполнении кэша.
- Высокие показатели могут указывать на часто меняющиеся данные и потенциальную неэффективность.
Промахи:
- Определение: Количество раз, когда кэш не смог предоставить запрошенные данные.
- Причины:
- Неэффективное кэширование: кэш слишком мал или не хранит нужные данные.
- Часто меняющиеся данные.
- Первичные запросы.
- Высокие показатели указывают на потенциальные проблемы с кэшированием.
Пропускает:
- Определение: Случаи, когда кэш вообще не использовался, хотя мог бы.
- Причины пропуска:
- Закупорка: характерна для обновлений Android Package Manager, намеренное отключение кэширования из-за большого объема вызовов во время загрузки.
- Unset: Кэш существует, но не инициализирован. Nonce был unset, что означает, что кэш никогда не был аннулирован.
- Обход: Намеренное решение пропустить кэш.
- Высокие показатели указывают на потенциальную неэффективность использования кэша.
Аннулирует:
- Определение: Процесс маркировки кэшированных данных как устаревших или неактуальных.
- Значимость: Подает сигнал о том, что система работает с самыми актуальными данными, предотвращая ошибки и несоответствия.
- Обычно инициируется сервером, которому принадлежат данные.
Текущий размер:
- Определение: Текущее количество элементов в кэше.
- Значимость: указывает на использование ресурсов кэша и потенциальное влияние на производительность системы.
- Более высокие значения обычно означают, что кэш использует больше памяти.
Макс. размер:
- Определение: Максимальный объем пространства, выделенного для кэша.
- Значимость: определяет емкость кэша и его способность хранить данные.
- Установка соответствующего максимального размера помогает сбалансировать эффективность кэша с использованием памяти. После достижения максимального размера добавляется новый элемент путем вытеснения наименее недавно использованного элемента, что может указывать на неэффективность.
Высшая точка:
- Определение: Максимальный размер, достигнутый кэшем с момента его создания.
- Значимость: дает представление о пиковом использовании кэша и потенциальной нагрузке на память.
- Мониторинг наивысшей точки может помочь выявить потенциальные узкие места или области для оптимизации.
Переполнения:
- Определение: количество раз, когда кэш превышал максимальный размер и приходилось удалять данные, чтобы освободить место для новых записей.
- Значимость: указывает на нехватку кэша и потенциальное снижение производительности из-за вытеснения данных.
- Высокие показатели переполнения указывают на необходимость корректировки размера кэша или переоценки стратегии кэширования.
Эту же статистику можно найти в отчете об ошибке.
Настройте размер кэша
Кэши имеют максимальный размер. При превышении максимального размера кэша записи вытесняются в порядке 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 Гц. Но гипотетически клиентский код в 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
.
Дедупликация обратных вызовов сервера в клиенте
Наконец, клиент API может регистрировать обратные вызовы на сервере 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"];
}
}