As chamadas de API do Android geralmente envolvem latência e computação significativas por invocação. O armazenamento em cache do lado do cliente é, portanto, uma consideração importante ao projetar APIs úteis, corretas e com bom desempenho.
Motivação
As APIs expostas aos desenvolvedores de apps no SDK do Android geralmente são implementadas como código de cliente no Android Framework que faz uma chamada IPC do Binder para um serviço do sistema em um processo de plataforma, cuja função é realizar algumas computações e retornar um resultado ao cliente. A latência dessa operação é normalmente dominada por três fatores:
- Overhead do IPC: uma chamada IPC básica normalmente tem 10.000 vezes a latência de uma chamada de método em processo básica.
- Contenção do lado do servidor: o trabalho feito no serviço do sistema em resposta à solicitação do cliente pode não começar imediatamente, por exemplo, se uma linha de execução do servidor estiver ocupada processando outras solicitações que chegaram antes.
- Computação do lado do servidor: o trabalho para processar a solicitação no servidor pode exigir muito trabalho.
É possível eliminar todos esses três fatores de latência implementando um cache no lado do cliente, desde que o cache seja:
- Correto: o cache do lado do cliente nunca retorna resultados diferentes dos que o servidor teria retornado.
- Eficaz: as solicitações do cliente geralmente são veiculadas pelo cache. Por exemplo, o cache tem uma taxa de acerto alta.
- Eficiente: o cache do lado do cliente faz uso eficiente dos recursos do lado do cliente, como representar dados em cache de maneira compacta e não armazenar muitos resultados em cache ou dados desatualizados na memória do cliente.
Considere armazenar em cache os resultados do servidor no cliente
Se os clientes fizerem a mesma solicitação várias vezes e o valor retornado não mudar ao longo do tempo, implemente um cache na biblioteca de cliente com chave pelos parâmetros de solicitação.
Use IpcDataCache
na sua implementação:
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);
}
}
Para conferir um exemplo completo, consulte android.app.admin.DevicePolicyManager
.
O IpcDataCache
está disponível para todo o código do sistema, incluindo os módulos principais.
Há também o PropertyInvalidatedCache
, que é quase idêntico, mas só é
visível para o framework. Prefira IpcDataCache
sempre que possível.
Invalidar caches em mudanças do lado do servidor
Se o valor retornado do servidor puder mudar com o tempo, implemente um callback para observar as mudanças e registre um callback para invalidar o cache do lado do cliente.
Invalidar caches entre casos de teste de unidade
Em um conjunto de testes de unidade, é possível testar o código do cliente em relação a um teste duplo em vez do servidor real. Se sim, limpe todos os caches do lado do cliente entre os casos de teste. Isso serve para manter os casos de teste mutuamente herméticos e evitar que um caso de teste interfira no outro.
@RunWith(AndroidJUnit4.class)
public class BirthdayManagerTest {
@Before
public void setUp() {
BirthdayManager.clearCache();
}
@After
public void tearDown() {
BirthdayManager.clearCache();
}
...
}
Ao escrever testes do CTS que executam um cliente de API que usa armazenamento em cache internamente, o cache é um detalhe de implementação que não é exposto ao autor da API. Portanto, os testes do CTS não precisam de nenhum conhecimento especial de armazenamento em cache usado no código do cliente.
Estudar ocorrências e ausências em cache
IpcDataCache
e PropertyInvalidatedCache
podem imprimir estatísticas em tempo real:
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
...
Campos
Hits:
- Definição: o número de vezes que um dado solicitado foi encontrado no cache.
- Significado: indica uma recuperação eficiente e rápida de dados, reduzindo a recuperação desnecessária de dados.
- Contagens mais altas geralmente são melhores.
Limpa:
- Definição: o número de vezes que o cache foi limpo devido à invalidação.
- Motivos para a exclusão:
- Invalidação: dados desatualizados do servidor.
- Gerenciamento de espaço: criar espaço para novos dados quando o cache estiver cheio.
- Contagens altas podem indicar dados que mudam com frequência e possíveis ineficiências.
Erros:
- Definição: o número de vezes que o cache não conseguiu fornecer os dados solicitados.
- Causas:
- Armazenamento em cache ineficiente: o cache é muito pequeno ou não armazena os dados corretos.
- Mudança frequente de dados.
- Solicitações de primeira vez.
- Contagens altas sugerem possíveis problemas de armazenamento em cache.
Pular:
- Definição: instâncias em que o cache não foi usado, mesmo que tivesse a possibilidade.
- Motivos para pular:
- Corking: específico para atualizações do Gerenciador de pacotes do Android, desativando de forma deliberada o armazenamento em cache devido a um grande volume de chamadas durante a inicialização.
- Não definido: o cache existe, mas não foi inicializado. O valor de uso único foi redefinido, o que significa que o cache nunca foi invalidado.
- Bypass: decisão intencional de pular o cache.
- Contagens altas indicam possíveis ineficiências no uso do cache.
Invalida:
- Definição: o processo de marcar dados em cache como desatualizados ou desatualizados.
- Significado: fornece um indicador de que o sistema funciona com os dados mais atualizados, evitando erros e inconsistências.
- Geralmente, é acionado pelo servidor que detém os dados.
Tamanho atual:
- Definição: a quantidade atual de elementos no cache.
- Importância: indica a utilização de recursos do cache e o possível impacto no desempenho do sistema.
- Valores mais altos geralmente significam que mais memória é usada pelo cache.
Tamanho máximo:
- Definição: a quantidade máxima de espaço alocada para o cache.
- Importância: determina a capacidade do cache e a capacidade de armazenar dados.
- Definir um tamanho máximo adequado ajuda a equilibrar a eficácia do cache com o uso de memória. Quando o tamanho máximo é alcançado, um novo elemento é adicionado removendo o elemento usado menos recentemente, o que pode indicar ineficiência.
Ponto máximo:
- Definição: o tamanho máximo alcançado pelo cache desde a criação.
- Importância: oferece insights sobre o uso máximo do cache e a possível pressão na memória.
- O monitoramento do nível máximo pode ajudar a identificar possíveis gargalos ou áreas para otimização.
Overflows:
- Definição: o número de vezes que o cache excedeu o tamanho máximo e teve que excluir dados para abrir espaço para novas entradas.
- Importância: indica a pressão do cache e a possível degradação de desempenho devido à eliminação de dados.
- Contagens de overflow altas sugerem que o tamanho do cache pode precisar ser ajustado ou que a estratégia de armazenamento em cache precisa ser reavaliada.
As mesmas estatísticas também podem ser encontradas em um relatório de bugs.
Ajustar o tamanho do cache
Os caches têm um tamanho máximo. Quando o tamanho máximo do cache é excedido, as entradas são excluídas na ordem LRU.
- Armazenar em cache poucas entradas pode afetar negativamente a taxa de ocorrência em cache.
- Armazenar muitas entradas em cache aumenta o uso de memória do cache.
Encontre o equilíbrio certo para seu caso de uso.
Eliminar chamadas de cliente redundantes
Os clientes podem fazer a mesma consulta ao servidor várias vezes em um curto período:
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();
}
}
Considere reutilizar os resultados de chamadas anteriores:
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();
}
}
Considere a memorização do lado do cliente de respostas recentes do servidor
Os apps clientes podem consultar a API em uma taxa mais rápida do que o servidor da API pode produzir novas respostas significativas. Nesse caso, uma abordagem eficaz é memorizar a última resposta do servidor no lado do cliente com um carimbo de data/hora e retornar o resultado memorizado sem consultar o servidor se o resultado memorizado for recente o suficiente. O autor do cliente da API pode determinar a duração da memorização.
Por exemplo, um app pode mostrar estatísticas de tráfego de rede para o usuário consultando as estatísticas em cada frame desenhado:
@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()));
}
O app pode renderizar frames a 60 Hz. No entanto, hipoteticamente, o código do cliente em
TrafficStats
pode optar por consultar o servidor para estatísticas no máximo uma vez por segundo,
e, se consultado em um segundo de uma consulta anterior, retornar o último valor visto.
Isso é permitido porque a documentação da API não fornece nenhum contrato
sobre a atualidade dos resultados retornados.
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
Considere a geração de código do lado do cliente em vez de consultas do servidor
Se os resultados da consulta forem conhecidos pelo servidor no momento da criação, considere se eles também são conhecidos pelo cliente no momento da criação e se a API pode ser implementada totalmente no lado do cliente.
Considere o código de app a seguir, que verifica se o dispositivo é um relógio (ou seja, se ele está executando o Wear OS):
public boolean isWatch(Context ctx) {
PackageManager pm = ctx.getPackageManager();
return pm.hasSystemFeature(PackageManager.FEATURE_WATCH);
}
Essa propriedade do dispositivo é conhecida no momento da criação, especificamente no momento
em que o framework foi criado para a imagem de inicialização do dispositivo. O código do lado do cliente
para hasSystemFeature
pode retornar um resultado conhecido imediatamente, em vez de
consultar o serviço do sistema PackageManager
remoto.
Eliminar a duplicação de callbacks do servidor no cliente
Por fim, o cliente da API pode registrar callbacks com o servidor da API para receber notificações de eventos.
É comum que os apps registrem vários callbacks para a mesma informação subjacente. Em vez de fazer com que o servidor notifique o cliente uma vez por callback registrado usando o IPC, a biblioteca de cliente precisa ter um callback registrado usando o IPC com o servidor e, em seguida, notificar cada callback registrado no app.
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"];
}
}