blob: f329a2fe54a0463a28a0e5b3ec0e4ebfaa1d69ae [file] [log] [blame]
/*
* Copyright 2012-present Facebook, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License. You may obtain
* a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
package com.facebook.buck.android;
import com.facebook.buck.android.DxStep.Option;
import com.facebook.buck.java.classes.ClasspathTraversal;
import com.facebook.buck.java.classes.ClasspathTraverser;
import com.facebook.buck.java.classes.DefaultClasspathTraverser;
import com.facebook.buck.java.classes.FileLike;
import com.facebook.buck.step.CompositeStep;
import com.facebook.buck.step.DefaultStepRunner;
import com.facebook.buck.step.ExecutionContext;
import com.facebook.buck.step.Step;
import com.facebook.buck.step.StepFailedException;
import com.facebook.buck.step.fs.RmStep;
import com.facebook.buck.step.fs.WriteFileStep;
import com.facebook.buck.step.fs.XzStep;
import com.facebook.buck.util.ProjectFilesystem;
import com.facebook.buck.zip.RepackZipEntriesStep;
import com.facebook.buck.zip.ZipStep;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Charsets;
import com.google.common.base.Function;
import com.google.common.base.Joiner;
import com.google.common.base.Optional;
import com.google.common.base.Preconditions;
import com.google.common.base.Throwables;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMultimap;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Iterables;
import com.google.common.collect.Lists;
import com.google.common.collect.Multimap;
import com.google.common.hash.Hasher;
import com.google.common.hash.Hashing;
import com.google.common.io.Files;
import com.google.common.util.concurrent.ListeningExecutorService;
import com.google.common.util.concurrent.MoreExecutors;
import java.io.File;
import java.io.IOException;
import java.nio.file.Path;
import java.util.EnumSet;
import java.util.List;
import java.util.Set;
import java.util.concurrent.Executors;
import javax.annotation.Nullable;
/**
* Optimized dx command runner which can invoke multiple dx commands in parallel and also avoid
* doing unnecessary dx invocations in the first place.
* <p>
* This is most appropriately represented as a build rule itself (which depends on individual dex
* rules) however this would require significant refactoring of AndroidBinaryRule that would be
* disruptive to other initiatives in flight (namely, ApkBuilder). It is also debatable that it is
* even the right course of action given that it would require dynamically modifying the DAG.
*/
public class SmartDexingStep implements Step {
private final InputResolver inputResolver;
private final Path successDir;
private final Optional<Integer> numThreads;
private final DexStore dexStore;
private final boolean optimizeDex;
private ListeningExecutorService dxExecutor;
/** Lazily initialized. See {@link InputResolver#createOutputToInputs(DexStore)}. */
private Multimap<File, File> outputToInputs;
/**
* @param primaryOutputPath Path for the primary dex artifact.
* @param primaryInputsToDex Set of paths to include as inputs for the primary dex artifact.
* @param secondaryOutputDir Directory path for the secondary dex artifacts, if there are any.
* Note that this directory will be pruned such that only those secondary outputs generated
* by this command will remain in the directory!
* @param secondaryInputsDir Directory path containing input jar files to use as dx input.
* Note that for each file in this directory, a separate dx invocation will be started with
* that file as input. Do not pass a directory that contains non-dexable artifacts!
* @param successDir Directory where success artifacts are written.
* @param numThreads Number of threads to use when invoking dx commands. If absent, a
* reasonable default will be selected based on the number of available processors.
* @param dexStore Specify the way secondary dexes are to be stored in the APK (e.g.
* within jar files, or as xz-compressed files).
*/
public SmartDexingStep(
String primaryOutputPath,
Set<String> primaryInputsToDex,
Optional<String> secondaryOutputDir,
Optional<String> secondaryInputsDir,
Path successDir,
Optional<Integer> numThreads,
DexStore dexStore,
boolean optimizeDex) {
this.inputResolver = new InputResolver(primaryOutputPath,
primaryInputsToDex,
secondaryOutputDir,
secondaryInputsDir);
this.successDir = Preconditions.checkNotNull(successDir);
this.numThreads = Preconditions.checkNotNull(numThreads);
this.dexStore = Preconditions.checkNotNull(dexStore);
this.optimizeDex = optimizeDex;
}
@VisibleForTesting
protected ListeningExecutorService createDxExecutor() {
int numThreadsValue;
if (numThreads.isPresent()) {
Preconditions.checkArgument(numThreads.get() >= 1,
"Must specify at least 1 thread on which to run dx");
numThreadsValue = numThreads.get();
} else {
numThreadsValue = determineOptimalThreadCount();
}
return MoreExecutors.listeningDecorator(Executors.newFixedThreadPool(numThreadsValue));
}
private final ListeningExecutorService getDxExecutor() {
if (dxExecutor == null) {
dxExecutor = createDxExecutor();
}
return dxExecutor;
}
private static int determineOptimalThreadCount() {
return (int)(1.25 * Runtime.getRuntime().availableProcessors());
}
private final Multimap<File, File> getOutputToInputsMultimap(
ProjectFilesystem projectFilesystem) {
if (outputToInputs == null) {
outputToInputs = inputResolver.createOutputToInputs(dexStore, projectFilesystem);
}
return outputToInputs;
}
@Override
public int execute(ExecutionContext context) {
ProjectFilesystem projectFilesystem = context.getProjectFilesystem();
try {
Multimap<File, File> outputToInputs = getOutputToInputsMultimap(projectFilesystem);
runDxCommands(context, outputToInputs);
if (inputResolver.hasSecondaryOutput()) {
removeExtraneousSecondaryArtifacts(
inputResolver.getSecondaryOutputDir(projectFilesystem),
outputToInputs.keySet(),
projectFilesystem);
}
return 0;
} catch (StepFailedException e) {
e.printStackTrace(context.getStdErr());
return 1;
} catch (IOException e) {
e.printStackTrace(context.getStdErr());
return 1;
}
}
private void runDxCommands(ExecutionContext context, Multimap<File, File> outputToInputs)
throws StepFailedException, IOException {
DefaultStepRunner stepRunner = new DefaultStepRunner(context, getDxExecutor());
// Invoke dx commands in parallel for maximum thread utilization. In testing, dx revealed
// itself to be CPU (and not I/O) bound making it a good candidate for parallelization.
List<Step> dxSteps = generateDxCommands(context, outputToInputs);
try {
stepRunner.runStepsInParallelAndWait(dxSteps);
} finally {
stepRunner.getListeningExecutorService().shutdownNow();
}
}
/**
* Prune the secondary output directory of any files that we didn't generate. This is
* needed because we crudely add all files in this directory to the final APK, but the number
* may have been reduced due to split-zip having less code to process.
* <p>
* This is also a defensive measure to cleanup extraneous artifacts left behind due to
* changes to buck itself.
*/
private void removeExtraneousSecondaryArtifacts(
File secondaryOutputDir,
Set<File> producedArtifacts,
ProjectFilesystem projectFilesystem) throws IOException {
for (File secondaryOutput : secondaryOutputDir.listFiles()) {
if (!producedArtifacts.contains(secondaryOutput)) {
projectFilesystem.rmdir(secondaryOutput.getAbsolutePath());
}
}
}
@Override
public String getShortName() {
return "smart_dex";
}
@Override
public String getDescription(ExecutionContext context) {
StringBuilder b = new StringBuilder();
b.append(getShortName());
b.append(' ');
Multimap<File, File> outputToInputs = getOutputToInputsMultimap(context.getProjectFilesystem());
for (File output : outputToInputs.keySet()) {
b.append("-out ");
b.append(output.getPath());
b.append("-in ");
Joiner.on(':').appendTo(b, outputToInputs.get(output));
}
return b.toString();
}
/**
* Once the {@code .class} files have been split into separate zip files, each must be converted
* to a {@code .dex} file.
*/
private List<Step> generateDxCommands(
ExecutionContext context,
Multimap<File, File> outputToInputs) throws IOException {
ImmutableList.Builder<DxPseudoRule> pseudoRules = ImmutableList.builder();
for (File outputFile : outputToInputs.keySet()) {
// This is silly to do so much conversion from String => File and back again but it is
// sort of a necessary evil since we're internally simulating a bridge between Java and
// the outside world (commands generally seen as external, and rules are generated by parsing
// JSON input).
pseudoRules.add(new DxPseudoRule(context,
ImmutableSet.copyOf(Iterables.transform(outputToInputs.get(outputFile), new Function<File, Path>() {
@Override
public Path apply(File input) {
return input.toPath();
}
})),
outputFile.getPath(),
context.getProjectFilesystem().resolve(successDir.resolve(outputFile.getName())),
optimizeDex));
}
ImmutableList.Builder<Step> commands = ImmutableList.builder();
for (DxPseudoRule pseudoRule : pseudoRules.build()) {
if (!pseudoRule.checkIsCached()) {
commands.addAll(pseudoRule.buildInternal());
}
}
return commands.build();
}
// This is a terrible shared kludge between SmartDexingCommand and SplitZipCommand.
// SplitZipCommand writes the metadata.txt file assuming this will be the final filename
// in the APK...
public static String transformInputToDexOutput(File file, DexStore dexStore) {
if (DexStore.XZ == dexStore) {
return Files.getNameWithoutExtension(file.getName()) + ".dex.jar.xz";
} else {
return Files.getNameWithoutExtension(file.getName()) + ".dex.jar";
}
}
// Helper class to break down the complex set of paths that this command accepts.
@VisibleForTesting
static class InputResolver {
private final String primaryOutputPath;
private final Set<String> primaryInputsToDex;
private final Optional<String> secondaryOutputDir;
private final Optional<String> secondaryInputsDir;
public InputResolver(
String primaryOutputPath,
Set<String> primaryInputsToDex,
Optional<String> secondaryOutputDir,
Optional<String> secondaryInputsDir) {
this.primaryOutputPath = Preconditions.checkNotNull(primaryOutputPath);
this.primaryInputsToDex = ImmutableSet.copyOf(primaryInputsToDex);
Preconditions.checkArgument(!(secondaryOutputDir.isPresent() ^ secondaryInputsDir.isPresent()),
"Secondary input and output must be passed together (either both absent or both present)");
this.secondaryOutputDir = secondaryOutputDir;
this.secondaryInputsDir = secondaryInputsDir;
}
/*
* Create a multimap whose keys are output files and whose values are inputs passed to the dx
* command. This defines a set of rules where the keySet of the returned multimap is the
* set of expected files to exist after smart dexing completes.
*/
public Multimap<File, File> createOutputToInputs(DexStore dexStore,
ProjectFilesystem projectFilesystem) {
final ImmutableMultimap.Builder<File, File> map = ImmutableMultimap.builder();
// Add the primary output.
File primaryOutputFile = projectFilesystem.getFileForRelativePath(primaryOutputPath);
for (String primaryInputToDex : primaryInputsToDex) {
map.put(primaryOutputFile, projectFilesystem.getFileForRelativePath(primaryInputToDex));
}
// Add all secondary outputs (one for each file in the secondary inputs dir).
if (secondaryInputsDir.isPresent()) {
File secondaryOutputDirFile = projectFilesystem.getFileForRelativePath(secondaryOutputDir.get());
File secondaryInputsDirFile = projectFilesystem.getFileForRelativePath(secondaryInputsDir.get());
for (File secondaryInputFile : secondaryInputsDirFile.listFiles()) {
// May be either directories or jar files, doesn't matter.
File secondaryOutputFile = new File(secondaryOutputDirFile,
transformInputToDexOutput(secondaryInputFile, dexStore));
map.put(secondaryOutputFile, secondaryInputFile);
}
}
return map.build();
}
public boolean hasSecondaryOutput() {
return secondaryOutputDir.isPresent();
}
public File getSecondaryOutputDir(ProjectFilesystem projectFilesystem) {
return projectFilesystem.getFileForRelativePath(secondaryOutputDir.get());
}
}
/**
* Internally designed to simulate a dexing buck rule so that once refactored more broadly as
* such it should be straightforward to convert this code.
* <p>
* This pseudo rule does not use the normal .success file model but instead checksums its
* inputs. This is because the input zip files are guaranteed to have changed on the
* filesystem (ZipSplitter will always write them out even if the same), but the contents
* contained in the zip may not have changed.
*/
@VisibleForTesting
static class DxPseudoRule {
private final ExecutionContext context;
private final Set<Path> srcs;
private final String outputPath;
private final Path outputHashPath;
private final boolean optimizeDex;
private String newInputsHash;
public DxPseudoRule(ExecutionContext context,
Set<Path> srcs,
String outputPath,
Path outputHashPath,
boolean optimizeDex) {
this.context = Preconditions.checkNotNull(context);
this.srcs = ImmutableSet.copyOf(srcs);
this.outputPath = Preconditions.checkNotNull(outputPath);
this.outputHashPath = Preconditions.checkNotNull(outputHashPath);
this.optimizeDex = optimizeDex;
}
/**
* Read the previous run's hash from the filesystem.
*
* @return Previous hash if there was one; null otherwise.
*/
@Nullable
private String getPreviousInputsHash() {
File outputHashFile = outputHashPath.toFile();
if (outputHashFile.exists()) {
try {
return Iterables.getFirst(
Files.readLines(outputHashFile, Charsets.UTF_8),
null);
} catch (IOException e) {
context.getStdErr().println(context.getAnsi().asWarningText(
String.format("Error reading success file: %s", outputHashPath)));
// Fall through, this is not fatal...
}
}
// This will trigger the dx command to run again.
return null;
}
@VisibleForTesting
String hashInputs() throws IOException {
final Hasher hasher = Hashing.sha1().newHasher();
// Hash all inputs in both srcs and entry order (which is very crudely expected to be stable
// across invocations). If it's not stable, all that means is that we'll run more dx commands
// than was necessary. Note that it is not possible to simply hash the inputs themselves
// for two reasons: 1) they may one day be directories, 2) zip files may contain the same
// entry contents but change on disk due to entry metadata.
ClasspathTraverser traverser = new DefaultClasspathTraverser();
try {
traverser.traverse(new ClasspathTraversal(srcs) {
@Override
public void visit(FileLike fileLike) {
try {
hasher.putBytes(fileLike.fastHash().asBytes());
} catch (IOException e) {
// Pass it along...
throw new RuntimeException(e);
}
}
});
} catch (RuntimeException e) {
Throwables.propagateIfInstanceOf(e.getCause(), IOException.class);
throw Throwables.propagate(e);
}
return hasher.hash().toString();
}
public boolean checkIsCached() throws IOException {
newInputsHash = hashInputs();
// Make sure the output dex file isn't newer than the output hash file.
long outputHashFileModTime = outputHashPath.toFile().lastModified();
long outputFileModTime = new File(outputPath).lastModified();
if (outputFileModTime > outputHashFileModTime) {
return false;
}
// Verify input hashes.
String currentInputsHash = getPreviousInputsHash();
return newInputsHash.equals(currentInputsHash);
}
/**
* Returns true if the output of dexing should be saved as .dex.jar. This is based on the
* target file extension, much as {@code dx} itself chooses whether to embed the dex inside
* a jar/zip based on the destination file passed to it.
*/
private boolean useXzCompression() {
return outputPath.endsWith(".dex.jar.xz");
}
public List<Step> buildInternal() {
Preconditions.checkState(newInputsHash != null, "Must call checkIsCached first!");
List<Step> steps = Lists.newArrayList();
EnumSet<Option> dxOptions = optimizeDex
? EnumSet.noneOf(DxStep.Option.class)
: EnumSet.of(DxStep.Option.NO_OPTIMIZE);
if (useXzCompression()) {
String tempDexJarOutput = outputPath.replaceAll("\\.jar\\.xz$", ".tmp.jar");
steps.add(new DxStep(tempDexJarOutput, srcs, dxOptions));
// We need to make sure classes.dex is STOREd in the .dex.jar file, otherwise .XZ
// compression won't be effective.
String repackedJar = outputPath.replaceAll("\\.xz$", "");
steps.add(new RepackZipEntriesStep(
tempDexJarOutput,
repackedJar,
ImmutableSet.of("classes.dex"),
ZipStep.MIN_COMPRESSION_LEVEL
));
steps.add(new RmStep(tempDexJarOutput, true));
steps.add(new XzStep(repackedJar));
} else {
steps.add(new DxStep(outputPath, srcs, dxOptions));
}
steps.add(new WriteFileStep(newInputsHash, outputHashPath));
// Use a composite step to ensure that runDxSteps can still make use of
// runStepsInParallelAndWait. This is necessary to keep the DxStep and
// WriteFileStep dependent in series.
return ImmutableList.<Step>of(new CompositeStep(steps));
}
}
}