A otimização do tamanho do APK é crucial para melhorar a taxa de conversão e a experiência do usuário, especialmente em regiões com conexões limitadas. Este artigo explora técnicas avançadas de redução de volume, abrangendo desde a compressão de imagens até a ofuscação de recursos binários.
Otimização Convencional de APK
A etapa inicial envolve a eliminação de redundâncias e a escolha inteligente de formatos de mídia.
1. Compressão de Imagens e Formatos
O uso de imagens vetorizadas (SVG) troca espaço em disco por ciclos de CPU, sendo ideal para ícones simples. Para bitmaps, o formato WebP é altamente recomendado. Ele oferece uma taxa de compressão superior ao PNG e JPEG, podendo reduzir o tamanho do arquivo em até 45% comparado ao PNG sem perdas. Embora a decodificação seja mais custosa, os dispositivos modernos lidam bem com essa sobrecarga. Ferramentas como o iSparta facilitam a conversão em massa.
2. Carregamento Dinâmico de Recursos
Recursos pesados, como pacotes de emojis, temas (skins) ou módulos funcionais, não precisam residir no APK principal. A arquitetura de plugins permite que esses sejam baixados sob demanda, reduzindo significativamente o binário inicial.
3. Limpeza com Lint
A ferramenta Lint identifica recursos não utilizados, layouts órfãos e strings redundantes. A remoção automatizada desses elementos baseada nas sugestões do Lint é um método eficaz de limpeza.
4. Compressão Extrema e Ofuscação de Código
O uso do algroitmo 7-Zip para reempacotar o APK often resulta em ganhos adicionais de tamanho. Paralelamente, o ProGuard deve ser ativado para remover código morto, comentários e renomear classes/métodos para nomes curtos (ex: a.java), dificultando a engenharia reversa e reduzindo o tamanho do DEX.
Princípios de Otimização de Recursos
Para entender a otimização profunda, é necessário analisar como o Android empacota recursos. Durante o build, recursos em res/ e AndroidManifest.xml são compilados para binário, enquanto assets/ e res/raw/ permanecem inalterados. O sistema gera um arquivo de índice chamado resources.arsc, que mapeia os IDs de recursos para os arquivos físicos.
O ponto central da otimização avançada é a Ofuscação de Recursos. O processo consiste em renomear arquivos de recursos (ex: res/drawable/ic_launcher.png para r/d/a.png) e atualizar as referências correspondentes na tabela resources.arsc e no arquivo R.java. Como os nomes dos arquivos e as strings na tabela de recursos são encurtados, o tamanho total do APK diminui consideravelmente.
Leitura de Binários Little-Endian
Para manipular o arquivo resources.arsc, é necessário utilizar classes auxiliares para ler dados no formato Little-Endian, padrão do Android. Abaixo uma implementação refatorada para leitura de fluxos binários:
import java.io.DataInput;
import java.io.DataInputStream;
import java.io.IOException;
import java.io.InputStream;
public final class BinaryStreamHandler implements DataInput {
private final DataInputStream internalStream;
private final InputStream rawStream;
private final byte[] tempBuffer;
public BinaryStreamHandler(InputStream in) {
this.rawStream = in;
this.internalStream = new DataInputStream(in);
this.tempBuffer = new byte[8];
}
public final void close() throws IOException {
this.internalStream.close();
}
public final int read(byte[] buffer, int offset, int length) throws IOException {
return this.rawStream.read(buffer, offset, length);
}
@Override
public final boolean readBoolean() throws IOException {
return this.internalStream.readBoolean();
}
@Override
public final byte readByte() throws IOException {
return this.internalStream.readByte();
}
@Override
public final char readChar() throws IOException {
this.internalStream.readFully(this.tempBuffer, 0, 2);
return (char) ((this.tempBuffer[1] & 0xFF) << 8 | (this.tempBuffer[0] & 0xFF));
}
@Override
public final double readDouble() throws IOException {
return Double.longBitsToDouble(readLong());
}
@Override
public final float readFloat() throws IOException {
return Float.intBitsToFloat(readInt());
}
@Override
public final void readFully(byte[] buffer) throws IOException {
this.internalStream.readFully(buffer, 0, buffer.length);
}
@Override
public final void readFully(byte[] buffer, int offset, int length) throws IOException {
this.internalStream.readFully(buffer, offset, length);
}
@Override
public final int readInt() throws IOException {
this.internalStream.readFully(this.tempBuffer, 0, 4);
return (tempBuffer[3] << 24)
| ((tempBuffer[2] & 0xFF) << 16)
| ((tempBuffer[1] & 0xFF) << 8)
| (tempBuffer[0] & 0xFF);
}
@Override
public final long readLong() throws IOException {
this.internalStream.readFully(this.tempBuffer, 0, 8);
long result = 0;
for (int i = 7; i >= 0; i--) {
result = (result << 8) | (this.tempBuffer[i] & 0xFF);
}
return result;
}
@Override
public final short readShort() throws IOException {
this.internalStream.readFully(this.tempBuffer, 0, 2);
return (short) ((this.tempBuffer[1] & 0xFF) << 8 | (this.tempBuffer[0] & 0xFF));
}
@Override
public final String readUTF() throws IOException {
return this.internalStream.readUTF();
}
@Override
public final int readUnsignedByte() throws IOException {
return this.internalStream.readUnsignedByte();
}
@Override
public final int readUnsignedShort() throws IOException {
this.internalStream.readFully(this.tempBuffer, 0, 2);
return ((this.tempBuffer[1] & 0xFF) << 8) | (this.tempBuffer[0] & 0xFF);
}
@Override
public final int skipBytes(int n) throws IOException {
return this.internalStream.skipBytes(n);
}
}
Análise do Arquivo resources.arsc
O exemplo abaixo demonstra como extrair metadados do arquivo de recursos. O código lê o cabeçalho da tabela, o pool de strings e o cabeçalho do pacote para entender a estrutura dos dados antes da ofuscação.
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;
public class ArscParserDemo {
public static void main(String[] args) {
ArscParserDemo parser = new ArscParserDemo();
try {
parser.parseArsc("C:/projects/demo/app-release.apk");
} catch (IOException e) {
e.printStackTrace();
}
}
public void parseArsc(String apkPath) throws IOException {
ZipFile zipArchive = new ZipFile(apkPath);
InputStream resourceStream = zipArchive.getInputStream(new ZipEntry("resources.arsc"));
BinaryStreamHandler reader = new BinaryStreamHandler(resourceStream);
// Leitura do Resource Table Header
short type = reader.readShort();
reader.skipBytes(2); // Header size
int packageSize = reader.readInt();
int totalPackages = reader.readInt();
System.out.println("Total de pacotes: " + totalPackages);
// Leitura do String Pool
reader.skipBytes(4); // Chunk header
int chunkSize = reader.readInt();
int stringCount = reader.readInt();
int styleCount = reader.readInt();
int flags = reader.readInt();
int stringsStart = reader.readInt();
int stylesStart = reader.readInt();
// Array de offsets das strings
int[] offsets = new int[stringCount];
for (int i = 0; i < stringCount; i++) {
offsets[i] = reader.readInt();
}
// Leitura dos estilos (se existirem)
if (styleCount > 0) {
for (int i = 0; i < styleCount; i++) {
reader.readInt();
}
}
// Extração do conteúdo das strings
int dataSize = ((stylesStart == 0) ? chunkSize : stylesStart) - stringsStart;
byte[] stringBuffer = new byte[dataSize];
reader.readFully(stringBuffer);
// Exemplo simples de impressão (assumindo ASCII para demo)
StringBuilder contentBuilder = new StringBuilder();
for (byte b : stringBuffer) {
contentBuilder.append((char) b);
}
System.out.println("Conteúdo do Pool: " + contentBuilder.toString());
// Próximo chunk: Package Header
reader.readShort(); // Type
reader.skipBytes(6); // Header size e size
int id = reader.readInt();
// Leitura do nome do pacote
StringBuilder nameBuilder = new StringBuilder();
int limit = 256;
while (limit-- != 0) {
short charVal = reader.readShort();
if (charVal == 0) break;
nameBuilder.append((char) charVal);
}
System.out.println("Nome do Pacote: " + nameBuilder.toString());
resourceStream.close();
}
}
Configuração e Execução
A automação desse processo geralmente é feita via configuração XML. O arquivo abaixo define regras para assinatura, uso de 7-Zip e white-list de recursos que não devem ser renomeados.
<?xml version="1.0" encoding="UTF-8"?>
<config>
<!-- Propriedades Gerais -->
<properties>
<!-- Ativa o uso do 7zip para recompactação máxima -->
<useSevenZip value="true"/>
<!-- Diretório de metadados padrão -->
<metaName value="META-INF"/>
<!-- Se true, mantém a estrutura 'res/drawable' original -->
<keepRoot value="false"/>
</properties>
<!-- Lista de permissões (White-list) -->
<whitelist isActive="true">
<!-- Ícone de lançador deve manter nome para compatibilidade externa -->
<resource value="com.exemplo.app.R.mipmap.ic_launcher"/>
</whitelist>
<!-- Mapeamento para builds incrementais -->
<keepmapping isActive="false">
<path value="mapping_resources.txt"/>
</keepmapping>
<!-- Configuração de Assinatura -->
<signature isActive="true">
<keystore path="C:/keys/release.keystore"/>
<storePass value="senha_armazenamento"/>
<keyPass value="senha_chave"/>
<alias value="alias_app"/>
</signature>
</config>
Alinhamento de Memória (ZipAlign)
O zipalign é uma ferramenta essencial que organiza os dados não comprimidos do APK em limites de 4 bytes. Como o Android utiliza memory mapping (mmap) para ler arquivos, o alinhamento correto permite que o sistema acesse os recursos diretamente da memória sem precisar copiar ou realizar operações de leitura complexas. Isso reduz o consumo de RAM e melhora o tempo de inicialização e a fluidez da aplicação.
A ferramenta deve ser executada no pacote final assinado. A sintaxe básica de verificação e alinhamento é:
zipalign -c -v 4 aplicacao.apk
A falha em realizar o alinhamento pode resultar em maior consumo de memória, travamentos e drástica redução da vida útil da bateria devido ao esforço extra do processador e sistema de E/S.