Otimização de Pacotes APK: Redução de Tamanho e Ofuscação de Recursos

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.

Tags: android APK Optimization WebP Resources.arsc ProGuard

Publicado em 8-20 19:56