Skip to content

Commit 66642d0

Browse files
authored
Migrated new APK size tool to OSS repository (#74)
* Migrated new APK size tool to OSS repository * Added README for APK sizing * Added human readable mode to generator
1 parent 8cb66a5 commit 66642d0

33 files changed

+1129
-0
lines changed
Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
// Copyright 2018 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
16+
package com.google.firebase.gradle
17+
18+
import org.gradle.api.DefaultTask
19+
import org.gradle.api.Task
20+
import org.gradle.api.tasks.InputFile
21+
import org.gradle.api.tasks.OutputFile
22+
import org.gradle.api.tasks.TaskAction
23+
24+
/**
25+
* Generates size measurements after building the test apps and outputs them as a text-format
26+
* protocol buffer report.
27+
*
28+
* <p>This task requires two properties, an SDK map, as input, and the report, as output. The map is
29+
* used to convert project names and build variants into the SDK identifiers used by the database.
30+
* The report path is where the output should be stored. Additionally, a project property, {@code
31+
* pull_request} is used in the report. Excluding this value will send a human-readable version
32+
* to standard out.
33+
*/
34+
public class GenerateMeasurementsTask extends DefaultTask {
35+
36+
/**
37+
* The file storing the SDK map.
38+
*
39+
* <p>This may be any type recognized by Gradle as a file. The format of the file's contents is
40+
* headerless CSV with a colon as a delimiter: projectName-buildVariant:sdkId. The first column
41+
* contains both the project name and build variant separated by an hyphen. The SDK ID is the
42+
* integer identifier used by the SQL database to represent this SDK and build variant pair.
43+
*
44+
* <p>A complete example follows:
45+
* <pre>{@code
46+
* database-debug:1
47+
* database-release:2
48+
* firestore-release:7
49+
* firestore-debug:4
50+
*}</pre>
51+
*/
52+
@InputFile
53+
File sdkMapFile
54+
55+
/**
56+
* The file for storing the report.
57+
*
58+
* <p>This may be any type recognized by Gradle as a file. The contents, if any, will be
59+
* overwritten by the new report.
60+
*/
61+
@OutputFile
62+
File reportFile
63+
64+
@Override
65+
Task configure(Closure closure) {
66+
project.android.variantFilter {
67+
if (it.buildType.name != "aggressive") {
68+
it.ignore = true;
69+
}
70+
}
71+
72+
outputs.upToDateWhen { false }
73+
dependsOn "assemble"
74+
return super.configure(closure)
75+
}
76+
77+
@TaskAction
78+
def generate() {
79+
// Check if we need to run human-readable or upload mode.
80+
if (project.hasProperty("pull_request")) {
81+
def pullRequestNumber = project.properties["pull_request"]
82+
def sdkMap = createSdkMap()
83+
def sizes = calculateSizesForUpload(sdkMap, project.android.applicationVariants)
84+
def report = createReportForUpload(pullRequestNumber, sizes)
85+
86+
reportFile.withWriter {
87+
it.write(report)
88+
}
89+
} else {
90+
def sizes = calculateHumanReadableSizes(project.android.applicationVariants)
91+
printHumanReadableReport(sizes)
92+
}
93+
}
94+
95+
private def calculateHumanReadableSizes(variants) {
96+
def sizes = [:]
97+
def processor = {flavor, build, size ->
98+
sizes[new Tuple2(flavor, build)] = size
99+
}
100+
101+
calculateSizesFor(variants, processor)
102+
return sizes
103+
}
104+
105+
private def calculateSizesForUpload(sdkMap, variants) {
106+
def sizes = [:]
107+
def processor = { flavor, build, size ->
108+
def name = "${flavor}-${build}"
109+
def sdk = sdkMap[name];
110+
111+
if (sdk == null) {
112+
throw new IllegalStateException("$name not included in SDK map")
113+
}
114+
sizes[sdk] = size
115+
}
116+
117+
calculateSizesFor(variants, processor)
118+
return sizes
119+
}
120+
121+
private def calculateSizesFor(variants, processor) {
122+
// Each variant should have exactly one APK. If there are multiple APKs, then this file is
123+
// out of sync with our Gradle configuration, and this task fails. If an APK is missing, it
124+
// is silently ignored, and the APKs from the other variants will be used to build the
125+
// report.
126+
variants.each { variant ->
127+
def flavorName = variant.flavorName
128+
def buildType = variant.buildType.name
129+
def apks = variant.outputs.findAll { it.outputFile.name.endsWith(".apk") }
130+
if (apks.size() > 1) {
131+
def msg = "${flavorName}-${buildType} produced more than one APK"
132+
throw new IllegalStateException(msg)
133+
}
134+
135+
// This runs at most once, as each variant at this point has zero or one APK.
136+
apks.each {
137+
def size = it.outputFile.size()
138+
processor.call(flavorName, buildType, size)
139+
}
140+
}
141+
}
142+
143+
private def printHumanReadableReport(sizes) {
144+
project.logger.quiet("|------------------ APK Sizes ------------------|")
145+
project.logger.quiet("|-- project --|-- build type --|-- size in bytes --|")
146+
147+
sizes.each { key, value ->
148+
def line = sprintf("|%-19s|%-19s|%-21s|", key.first, key.second, value)
149+
project.logger.quiet(line)
150+
}
151+
}
152+
153+
// TODO(allisonbm): Remove hard-coding protocol buffers. This code manually generates the
154+
// text-format protocol buffer report. This eliminates requiring buildSrc to depend on the
155+
// uploader (or simply, the protocol buffer library), but this isn't the most scalable option.
156+
private def createReportForUpload(pullRequestNumber, sizes) {
157+
def sdkId = 0
158+
def apkSize = 0
159+
160+
def pullRequestGroup = """
161+
groups {
162+
table_name: "PullRequests"
163+
column_names: "pull_request_id"
164+
measurements {
165+
values {
166+
int_value: ${pullRequestNumber}
167+
}
168+
}
169+
}
170+
"""
171+
172+
def sizeGroupHeader = """
173+
groups {
174+
table_name: "ApkSizes"
175+
column_names: "sdk_id"
176+
column_names: "pull_request_id"
177+
column_names: "apk_size"
178+
"""
179+
180+
def sizeGroupEntry = """
181+
measurements {
182+
values {
183+
int_value: ${->sdkId}
184+
}
185+
values {
186+
int_value: ${pullRequestNumber}
187+
}
188+
values {
189+
int_value: ${->apkSize}
190+
}
191+
}
192+
"""
193+
194+
def sizeGroupFooter = """
195+
}
196+
"""
197+
198+
199+
def builder = new StringBuilder()
200+
builder.append(pullRequestGroup)
201+
builder.append(sizeGroupHeader)
202+
203+
sizes.each { key, value ->
204+
// sdkId and apkSize are lazily interpolated into sizeGroupEntry.
205+
sdkId = key
206+
apkSize = value
207+
builder.append(sizeGroupEntry)
208+
}
209+
210+
builder.append(sizeGroupFooter)
211+
return builder.toString()
212+
}
213+
214+
private def createSdkMap() {
215+
def map = [:]
216+
217+
sdkMapFile.eachLine {
218+
def delimiter = it.indexOf(":")
219+
def key = it.substring(0, delimiter).trim()
220+
def value = it.substring(delimiter + 1).trim()
221+
map[key] = Integer.parseInt(value)
222+
}
223+
224+
return map
225+
}
226+
}
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
// Copyright 2018 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
16+
package com.google.firebase.gradle
17+
18+
import java.net.URL
19+
import java.nio.file.Files
20+
import java.nio.file.StandardCopyOption
21+
import org.gradle.api.DefaultTask
22+
import org.gradle.api.Task
23+
import org.gradle.api.tasks.Input
24+
import org.gradle.api.tasks.InputFile
25+
import org.gradle.api.tasks.TaskAction
26+
27+
/**
28+
* Takes the size information created by {@link GenerateMeasurementTask} and uploads it to the
29+
* database using the uploader tool.
30+
*
31+
* <p>The uploader tool is fetched from the Internet using a URL. This URL, and the path to the
32+
* report to upload, must be given as properties to this task. This task also requires a project
33+
* property, {@code database_config} for connecting to the database. The format of this file is
34+
* dictated by the uploader tool.
35+
*/
36+
public class UploadMeasurementsTask extends DefaultTask {
37+
38+
/**
39+
* The URL of the uploader tool.
40+
*
41+
* <p>This must be a valid URL as a {@link String}.
42+
*/
43+
@Input
44+
String uploader
45+
46+
/**
47+
* The file to upload.
48+
*
49+
* <p>This file must exist prior to executing this task, but it may be created by other tasks
50+
* provided they run first.
51+
*/
52+
@InputFile
53+
File reportFile
54+
55+
@TaskAction
56+
def upload() {
57+
if (!project.hasProperty("database_config")) {
58+
throw new IllegalStateException("Cannot upload measurements without database config")
59+
}
60+
61+
def configuration = project.file(project.properties["database_config"])
62+
63+
withTempJar { jar ->
64+
getUploaderUrl().withInputStream {
65+
Files.copy(it, jar, StandardCopyOption.REPLACE_EXISTING)
66+
}
67+
68+
project.exec {
69+
executable("java")
70+
71+
args(
72+
"-jar",
73+
jar,
74+
"--config_path=${configuration}",
75+
"--proto_path=${reportFile}",
76+
)
77+
}.rethrowFailure()
78+
}
79+
}
80+
81+
def getUploaderUrl() {
82+
return new URL(uploader)
83+
}
84+
85+
private def withTempJar(Closure action) {
86+
def path = Files.createTempFile("uploader", ".jar")
87+
try {
88+
action.call(path)
89+
} finally {
90+
Files.delete(path);
91+
}
92+
}
93+
}

subprojects.cfg

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,5 @@ fiamui-app
88
firebase-storage
99
protolite-well-known-types
1010

11+
tools:apksize
1112
tools:errorprone

tools/apksize/README.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# APK Size Tooling
2+
3+
## Purpose
4+
5+
This tooling measures the size of APKs using Firebase. The APKs are simple apps
6+
that exercise only a small faction of the API surface. These numbers help to
7+
show how an app's size might grow if Firebase is included.
8+
9+
## How to Use
10+
11+
There are two tasks defined in this subproject: generateMeasurements and
12+
uploadMeasurements. The former gathers the measurements and writes them to a
13+
file in the build directory. The latter is invoked by CI and uploads the report
14+
to an SQL database.
15+
16+
The generateMeasurements task may be manually run with `./gradlew -q
17+
generateMeasurements`. This will output a human readable report to standard out.
18+
Appending `-Ppull_request=999` will instead generate the report to upload, where
19+
`999` is the pull request number to place in the report.
20+
21+
The uploadMeasurements task is not intended to be invoked manually. However, it
22+
may be invoked with the above pull request flag and `-Pdatabase_config=path`
23+
where `path` is the path to the config file. The config file must have the
24+
following structure where the values in all-caps are placeholders for the
25+
relevant pieces of configuration:
26+
27+
```
28+
host:HOST
29+
database:DATABASE
30+
user:USER
31+
password:PASSWORD
32+
```
33+
34+
## Current Support
35+
36+
All projects in this repository are supported with an aggressive ProGuard
37+
profile. Less aggressive ProGuard profiles will be added at a future date.

0 commit comments

Comments
 (0)