Skip to content
academia.sh

Lesson 12 / 19

Code Shrinking and Obfuscation

Unused classes are stripped from the published app package and the names of the remaining classes are shortened. The lesson measures in how many modules the remaining class count exceeds the budget after shrinking, shows that obfuscation does not change this count, and shows that a broad keep rule defeats shrinking.

Published Updated

In this lesson

  • Defines shrinking, obfuscation and the keep rule, and the job of the mapping file
  • Measures from the model that reachability analysis keeps the classes reachable from the root and discards the rest
  • Shows that obfuscation changes class names but not the remaining class count
  • Distinguishes how a broad keep rule written for a package reached by reflection defeats shrinking
Contents

In the previous lesson we built the way to measure whether a change slowed the work down. This lesson looks at how the measured thing enters the package that reaches the user. A published app does not have to carry every class written during development. If the app calls only a few of a library’s hundreds of classes, the classes that are never called only take up space when they enter the package; they enlarge the download and increase the loading work.

Reducing the code that enters the package is two separate operations. Shrinking keeps classes and members that cannot be reached from the app’s entry points out of the package. Obfuscation replaces the names of the remaining classes and members with short, meaningless names. Both run at build time, in the step that produces the release package; they do not touch the source code written during development. Before a shrinker removes code, it analyzes whether that code can be reached, and the analysis cannot see code reached by reflection. For this reason the developer declares the classes that must not be discarded with a keep rule.

The measure is the course’s measure again: in how many modules the number of classes remaining in a module after shrinking exceeds the budget.

// Overrun.kt — classifies in how many samples a measurement exceeds its budget
class Budget(val name: String, val limit: Int)

class Overrun(val label: String, val budget: Budget, samples: List<Int>) {
    val total: Int = samples.size
    val over: Int = samples.count { it > budget.limit }
    val verdict: String = when {
        over == 0 -> "within"
        over == total -> "all"
        over * 2 < total -> "minority"
        else -> "majority"
    }

    fun line() {
        println("$label: budget=${budget.name}<=${budget.limit} | over=$over/$total -> $verdict")
    }
}

fun countOverruns(overruns: List<Overrun>) {
    for (name in listOf("within", "minority", "majority", "all")) {
        println("$name: ${overruns.count { it.verdict == name }}")
    }
}

Budget carries the name and limit of a measurement, and Overrun counts the samples that exceed the limit and gives one of four overruns. This file is the same in every lesson of the course.

The Reachability Analysis Model

In the model a module consists of class numbers and the call edges between classes. The app’s entry point is class number zero; starting from that point the shrinker follows the call edges and keeps the classes it reaches. It discards the classes it cannot reach. A class reached by reflection carries no call edge; the shrinker does not see it, and it stays only thanks to a keep rule.

// Shrink.kt — classes reachable from the root stay, the others are discarded
class Module(val name: String, val edges: Map<Int, List<Int>>, val total: Int)

fun buildModule(name: String, total: Int, used: Int): Module =
    Module(name, (0 until used - 1).associateWith { listOf(it + 1) }, total)

fun sampleModules(): List<Module> {
    val sizes = listOf(12 to 4, 20 to 6, 14 to 3, 30 to 9, 16 to 5, 11 to 4, 24 to 7, 14 to 5)
    return sizes.mapIndexed { index, (total, used) -> buildModule("module-${index + 1}", total, used) }
}

fun reachableFrom(module: Module, root: Int): Set<Int> {
    val seen = mutableSetOf(root)
    val queue = ArrayDeque(listOf(root))
    while (queue.isNotEmpty()) {
        val current = queue.removeFirst()
        for (next in module.edges[current].orEmpty()) {
            if (seen.add(next)) queue.addLast(next)
        }
    }
    return seen
}

fun keptClasses(module: Module, rule: String): Int = when (rule) {
    "shrink", "obfuscate" -> reachableFrom(module, 0).size
    "narrow" -> (reachableFrom(module, 0) + (module.total - 1)).size
    else -> module.total
}

fun mappingLines(module: Module): List<String> =
    reachableFrom(module, 0).sorted().map { "C$it -> ${'a' + it}" }

sampleModules builds eight modules; each module carries total classes and used of them are reachable from the entry point. reachableFrom finds the reachable classes. keptClasses gives the number of classes remaining in a module according to the rule: shrink keeps only the reachable ones, obfuscate gives the same count, narrow adds to the reachable ones the single class reached by reflection, and the other rules keep all the classes of the module. mappingLines returns the correspondences of the names obfuscation writes. We first leave shrinking on and measure the remaining class count with a budget of ten classes.

// Main.kt — the class count remaining after shrinking is measured per module
fun main() {
    val modules = sampleModules()
    val samples = modules.map { keptClasses(it, "shrink") }
    println("kept: $samples")

    val overrun = Overrun("shrink", Budget("class", 10), samples)
    overrun.line()
    countOverruns(listOf(overrun))
}
kept: [4, 6, 3, 9, 5, 4, 7, 5]
shrink: budget=class<=10 | over=0/8 -> within
within: 1
minority: 0
majority: 0
all: 0

In no module did the remaining class count reach ten: within. The rule counts not whether this code goes to production but in how many samples the work remaining after shrinking exceeds the budget.

PD1. In the model the class count stands in for a package size and every class has the same weight. The size of a real class varies from a few lines to thousands of lines; the model counts classes, does not set up how much space each takes, and does not state by what ratio the package shrank. Whether the number remaining after shrinking is right against the budget is decided by the product, not by the model.

With Shrinking Off

To see the gain from shrinking, we measure the same eight modules without shrinking: in that case every module carries all its classes.

// Main.kt — the same budget with shrinking off and on
fun main() {
    val modules = sampleModules()
    val closed = modules.map { keptClasses(it, "closed") }
    val shrunk = modules.map { keptClasses(it, "shrink") }
    println("closed: $closed")
    println("shrink: $shrunk")

    val overruns = listOf(
        Overrun("closed", Budget("class", 10), closed),
        Overrun("shrink", Budget("class", 10), shrunk),
    )
    overruns.forEach { it.line() }
    countOverruns(overruns)
}
closed: [12, 20, 14, 30, 16, 11, 24, 14]
shrink: [4, 6, 3, 9, 5, 4, 7, 5]
closed: budget=class<=10 | over=8/8 -> all
shrink: budget=class<=10 | over=0/8 -> within
within: 1
minority: 0
majority: 0
all: 1

With shrinking off, every module carried a class count near ten or past it: all. With shrinking on, all eight stayed within the budget. The difference lies not in the code itself changing but in the code that enters the package decreasing.

On the platform this decision is made in the build script. The piece below turns shrinking on for the release build and attaches the rule file.

// Platform code: turning on shrinking in the release build
android {
    buildTypes {
        release {
            isMinifyEnabled = true
            proguardFiles(
                getDefaultProguardFile("proguard-android-optimize.txt"),
                "proguard-rules.pro",
            )
        }
    }
}

This piece is platform code, and here we claim no output, size or duration. The model sets up the rule of this measurement; the model is not the tool itself. Which classes the shrinker counts as reachable in a real app is information the model does not show, and it is read from the reports produced at the end of the build.

Obfuscation Does Not Change the Count

Obfuscation shortens the names of classes. It does not change the remaining class count, because it discards no class; it only changes its name. In addition, the correspondences between the original names and the short names are written to a mapping file. Without this file the short names in crash traces cannot be translated back to the original names.

// Main.kt — obfuscation changes the names, not the class count
fun main() {
    val modules = sampleModules()
    println("mapping:")
    mappingLines(modules[0]).forEach { println("  $it") }

    val overruns = listOf("shrink", "obfuscate").map { rule ->
        Overrun(rule, Budget("class", 10), modules.map { keptClasses(it, rule) })
    }
    overruns.forEach { it.line() }
    countOverruns(overruns)
}
mapping:
  C0 -> a
  C1 -> b
  C2 -> c
  C3 -> d
shrink: budget=class<=10 | over=0/8 -> within
obfuscate: budget=class<=10 | over=0/8 -> within
within: 2
minority: 0
majority: 0
all: 0

The four classes of the first module were shortened from a to d, and the two lines gave the same overrun. Obfuscation alone does not affect the budget; it serves a separate purpose, that is, making the names unreadable. Losing the mapping file, on the other hand, leads to the short names being impossible to resolve. Because this lesson’s measurement looks only at the class count, it does not see the presence or absence of the mapping file.

PD2. In the model obfuscation replaces a name with a single letter and no collision occurs. A real obfuscator shortens many classes in nested namespaces, keeps some names (for example names looked up by reflection) and regenerates the mapping file in every build. The mapping file of an earlier build does not resolve the names of a later build.

What Shrinking Solves

The aim of shrinking is to zero the cost of code that does not enter the package. If code does not enter the package, it is not downloaded, not loaded, and not scanned during loading. But this gain holds only for unreachable code. A class reachable from an entry point stays in the package even if the user never calls it, because the shrinker analyzes not the use at run time but the structural reachability of the code.

For this reason the number remaining after shrinking is not a lower bound but an upper estimate. In the model reachability is a chain: each class calls the next. In real apps the call graph is far more branched, and the number of classes the shrinker leaves varies with the shape of the graph.

A keep rule has a cost, and this cost escapes the eye of the person writing it. Every rule tells the shrinker “keep this” instead of “discard this”. Broadening a rule is easy, because the error shows only at run time: a missing rule crashes the app, an excess rule only enlarges the package. Because of this asymmetry rules widen over time and the gain from shrinking quietly erodes. The numeric measurement in this lesson makes exactly this erosion visible with a budget.

Writing a narrow rule requires knowing which class is reached by reflection and why. This information is most often in a library’s documentation, or in your own code on the line where a string of characters is turned into a class at run time. Writing the reason next to the rule leaves a trace with which the next person safely narrows or removes the rule.

Testing the release build is a separate job as well. Since shrinking is usually turned on only in the release build, an app that runs during development can crash after shrinking. For this reason the shrunk build is run end to end at least once, and the paths reached by reflection are walked in particular. Catching the crash beforehand comes cheaper than writing the keep rule along with its reason.

The result of shrinking changes as the app is updated. Adding a new library increases the reachable class count, removing an unused feature decreases it. For this reason the remaining class count is not measured once and forgotten; in every release build it is counted again with the same budget, and a module that exceeds the budget is treated as a regression that raises the question of which change enlarged it. The eight modules of this lesson are a snapshot of a single moment of this monitoring.

Keeping the mapping file is a process question. The file is produced together with the build and must match the published package; the file of the wrong build translates the short names to wrong original names and misleads the crash investigation. For this reason the file is archived together with the version number at the moment the package is published.

Common Misconceptions

“Obfuscation shrinks the package.” Obfuscation shortens names; this may save space when names are long, but it does not reduce the class count. In the lesson’s obfuscation measurement the two lines gave the same overrun.

“Obfuscation makes code secure.” Obfuscation makes code harder to read, but it provides no protection. Short names do not prevent the code from being decoded, they only slow it down. Information that must be kept secret should never enter the package.

“Writing a keep rule solves the problem.” A keep rule saves the class reached by reflection, but the breadth of the rule determines the cost. A broad rule keeps the whole package and erases the gain from shrinking. The lesson’s bounding measurement showed this.

“The mapping file is optional.” Without the mapping file, the short names in crash traces cannot be translated to the original names. The mapping file of every release build is stored together with that build.

The Budget and What the Rule Sees

The class-count budget states the most classes a module may carry after shrinking. There is no single right way to compute the budget: some projects use the average of the previous version, some use a distribution working backward from the package’s total limit. The rule does not know where the budget comes from; it only applies it to the samples.

The Bounding Measurement

The shrinker cannot see code reached by reflection. If there is a single class reached by reflection, that class must be kept. We can do this in two ways. A narrow rule keeps only that class. A broad rule keeps everything in that class’s package. On the platform their counterparts in the rule file are as follows.

# narrow: only the class reached by reflection is kept
-keep class com.example.plugin.PluginEntry { *; }

# broad: every class in the package is kept
-keep class com.example.plugin.** { *; }

This rule file is not run and has no output; the two lines give two different answers to the same reflection question. We measure the same two answers in the model.

// Main.kt — a narrow and a broad keep rule are measured under the same budget
fun main() {
    val modules = sampleModules()
    val narrow = modules.map { keptClasses(it, "narrow") }
    val broad = modules.map { keptClasses(it, "broad") }
    println("narrow: $narrow")
    println("broad: $broad")

    val overruns = listOf(
        Overrun("narrow", Budget("class", 10), narrow),
        Overrun("broad", Budget("class", 10), broad),
    )
    overruns.forEach { it.line() }
    countOverruns(overruns)
}
narrow: [5, 7, 4, 10, 6, 5, 8, 6]
broad: [12, 20, 14, 30, 16, 11, 24, 14]
narrow: budget=class<=10 | over=0/8 -> within
broad: budget=class<=10 | over=8/8 -> all
within: 1
minority: 0
majority: 0
all: 1

The narrow rule added to the reachable classes only the class reached by reflection and stayed within; the fourth module is exactly at ten and, being equal to the limit, was not counted as an overrun. The broad rule, on the other hand, also kept the unused classes in every module and gave the same values as the numbers measured with shrinking off: all. How the broad rule defeats shrinking is read from these two lines: writing the rule did not turn the shrinker off, but it produced the same count as the state in which it is off.

The rule sees how many samples overrun, not why. From a line that came out all, one cannot read why the remaining classes remained; for that, which keep rule holds which class is looked up in the reports the shrinker writes at the end of the build. Obfuscation fixes nothing here either: even if names are shortened, the count stays the same.

PD3. In the model the broad rule keeps every class in the package, and the package is the same as the module. A real keep rule may keep only certain members or only certain name patterns; the two rules written in this lesson show the narrowest and the broadest ends. The result of rules that fall between them cannot be derived from the model without measuring.

Summary

  • This lesson’s overrun: within. With shrinking on, the remaining class count did not exceed the ten-class budget in any of the eight modules.
  • Shrinking discards classes that cannot be reached from the entry point. Obfuscation only shortens names; the remaining class count does not change, and the mapping file is kept to translate the short names back.
  • With shrinking off, the same eight modules exceeded the budget in all. The difference came not from the code changing but from the code entering the package decreasing.
  • The narrow keep rule stayed within. The broad keep rule covering the package reached by reflection also kept the unused classes in every module, and the result came out all.
  • The rule sees how many samples overrun, not why. Which rule keeps the remaining classes is read from the shrinker’s reports.

Next Step

After the code is shrunk and obfuscated, the app turns into a package, and this package has more than one format: the format that carries everything in a single file and the format that produces device-specific parts. The next lesson, App Package Formats, measures what these formats distribute and how and in how many device types the number of parts downloaded per device exceeds the budget.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close