Speed up RClassGeneratorAction (StarlarkRClassGenerator) for large apps.

Applies various performance optimizations.

PiperOrigin-RevId: 992580832
Change-Id: Ic6e393c7460efaf67d48328340e7ea76bf62e786
diff --git a/src/tools/java/com/google/devtools/build/android/AndroidCompiledResourceMergingAction.java b/src/tools/java/com/google/devtools/build/android/AndroidCompiledResourceMergingAction.java
index da6c435..f516e16 100644
--- a/src/tools/java/com/google/devtools/build/android/AndroidCompiledResourceMergingAction.java
+++ b/src/tools/java/com/google/devtools/build/android/AndroidCompiledResourceMergingAction.java
@@ -201,7 +201,11 @@
       logger.fine(String.format("Merging finished at %sms", timer.elapsed(TimeUnit.MILLISECONDS)));
 
       AndroidResourceOutputs.createClassJar(
-          generatedSources, options.classJarOutput, options.targetLabel, options.injectingRuleKind);
+          generatedSources,
+          resourceClassWriter.getClassFiles(),
+          options.classJarOutput,
+          options.targetLabel,
+          options.injectingRuleKind);
       logger.fine(
           String.format("Create classJar finished at %sms", timer.elapsed(TimeUnit.MILLISECONDS)));
 
diff --git a/src/tools/java/com/google/devtools/build/android/AndroidResourceClassWriter.java b/src/tools/java/com/google/devtools/build/android/AndroidResourceClassWriter.java
index 8907dd6..b00170b 100644
--- a/src/tools/java/com/google/devtools/build/android/AndroidResourceClassWriter.java
+++ b/src/tools/java/com/google/devtools/build/android/AndroidResourceClassWriter.java
@@ -22,7 +22,10 @@
 import com.google.devtools.build.android.resources.Visibility;
 import java.io.IOException;
 import java.nio.file.Path;
+import java.util.Collections;
 import java.util.Map;
+import java.util.SortedMap;
+import java.util.concurrent.ConcurrentSkipListMap;
 
 /**
  * Generates the R class for an android_library with made up field initializers for the ids. The
@@ -67,6 +70,7 @@
   private boolean includeClassFile = true;
   private boolean includeJavaFile = true;
   private boolean annotateTransitiveFields = false;
+  private final ConcurrentSkipListMap<Path, byte[]> classFiles = new ConcurrentSkipListMap<>();
 
   private final PlaceholderIdFieldInitializerBuilder generator;
 
@@ -93,6 +97,14 @@
     this.annotateTransitiveFields = annotateTransitiveFields;
   }
 
+  /**
+   * Returns the generated class files, sorted and keyed by their path under the output base path.
+   * Class files are not written to disk.
+   */
+  SortedMap<Path, byte[]> getClassFiles() {
+    return Collections.unmodifiableSortedMap(classFiles);
+  }
+
   @Override
   protected void acceptSimpleResourceImpl(
       DependencyInfo dependencyInfo, Visibility visibility, ResourceType type, String name) {
@@ -129,9 +141,10 @@
   }
 
   private void writeAsClass(FieldInitializers initializers) throws IOException {
-    RClassGenerator.with(
+    RClassGenerator.inMemory(
             label,
             outputBasePath,
+            classFiles,
             initializers,
             /* finalFields= */ false,
             annotateTransitiveFields,
diff --git a/src/tools/java/com/google/devtools/build/android/AndroidResourceOutputs.java b/src/tools/java/com/google/devtools/build/android/AndroidResourceOutputs.java
index f2bfb83..3cfad96 100644
--- a/src/tools/java/com/google/devtools/build/android/AndroidResourceOutputs.java
+++ b/src/tools/java/com/google/devtools/build/android/AndroidResourceOutputs.java
@@ -39,8 +39,10 @@
 import java.util.GregorianCalendar;
 import java.util.LinkedHashSet;
 import java.util.List;
+import java.util.Map;
 import java.util.Objects;
 import java.util.Set;
+import java.util.SortedMap;
 import java.util.jar.Attributes;
 import java.util.jar.Manifest;
 import java.util.regex.Matcher;
@@ -282,16 +284,6 @@
       return out.toByteArray();
     }
 
-    @Override
-    protected void writeEntry(Path file) throws IOException {
-      Path filename = file.getFileName();
-      String name = filename.toString();
-      if (name.endsWith(".class")) {
-        byte[] content = Files.readAllBytes(file);
-        addEntry(file, content);
-      }
-    }
-
     void writeManifestContent(@Nullable String targetLabel, @Nullable String injectingRuleKind)
         throws IOException {
       addEntry("META-INF/", new byte[] {});
@@ -476,9 +468,13 @@
     }
   }
 
-  /** Creates a zip archive from all found R.class (and inner class) files. */
+  /**
+   * Creates a zip archive from R.class (and inner class) file contents, keyed by their path under
+   * {@code generatedClassesRoot}.
+   */
   public static void createClassJar(
       Path generatedClassesRoot,
+      SortedMap<Path, byte[]> classFiles,
       Path classJar,
       @Nullable String targetLabel,
       @Nullable String injectingRuleKind) {
@@ -486,9 +482,10 @@
       Files.createDirectories(classJar.getParent());
       try (final ZipBuilder zip = ZipBuilder.createFor(classJar)) {
         ClassJarBuildingVisitor visitor = new ClassJarBuildingVisitor(zip, generatedClassesRoot);
-        Files.walkFileTree(generatedClassesRoot, visitor);
         visitor.writeManifestContent(targetLabel, injectingRuleKind);
-        visitor.writeEntries();
+        for (Map.Entry<Path, byte[]> classFile : classFiles.entrySet()) {
+          visitor.addEntry(classFile.getKey(), classFile.getValue());
+        }
       }
     } catch (IOException e) {
       throw new RuntimeException(e);
diff --git a/src/tools/java/com/google/devtools/build/android/RClassGeneratorAction.java b/src/tools/java/com/google/devtools/build/android/RClassGeneratorAction.java
index 0d0550e..d191509 100644
--- a/src/tools/java/com/google/devtools/build/android/RClassGeneratorAction.java
+++ b/src/tools/java/com/google/devtools/build/android/RClassGeneratorAction.java
@@ -28,9 +28,9 @@
 import com.google.devtools.build.android.Converters.NoOpSplitter;
 import com.google.devtools.build.android.resources.RPackageId;
 import com.google.devtools.build.android.resources.ResourceSymbols;
-import java.nio.file.Files;
 import java.nio.file.Path;
 import java.util.List;
+import java.util.concurrent.ConcurrentSkipListMap;
 import java.util.concurrent.TimeUnit;
 import java.util.logging.Logger;
 
@@ -130,10 +130,11 @@
 
     Preconditions.checkNotNull(options.classJarOutput);
     final AndroidResourceProcessor resourceProcessor = new AndroidResourceProcessor(STD_LOGGER);
-    try (ScopedTemporaryDirectory scopedTmp =
-        new ScopedTemporaryDirectory("android_res_compile_tmp")) {
-      Path tmp = scopedTmp.getPath();
-      Path classOutPath = tmp.resolve("compiled_classes");
+    try {
+      // Class files are generated in memory, keyed (and sorted) by the path they would have if
+      // written to disk under classOutPath.
+      Path classOutPath = Path.of("compiled_classes");
+      ConcurrentSkipListMap<Path, byte[]> classFiles = new ConcurrentSkipListMap<>();
 
       logger.fine(String.format("Setup finished at %sms", timer.elapsed(TimeUnit.MILLISECONDS)));
       // Note that we need to write the R class for the main binary (so proceed even if there
@@ -159,7 +160,12 @@
             options.useRPackage ? RPackageId.createFor(appPackageName) : null;
         // For now, assuming not used for libraries and setting final access for fields.
         fullSymbolValues.writeClassesTo(
-            libSymbolMap, appPackageName, classOutPath, options.finalFields, rPackageId);
+            libSymbolMap,
+            appPackageName,
+            classOutPath,
+            classFiles,
+            options.finalFields,
+            rPackageId);
         logger.fine(
             String.format("Finished R.class at %sms", timer.elapsed(TimeUnit.MILLISECONDS)));
       } else if (!options.libraries.isEmpty()) {
@@ -170,16 +176,17 @@
             String.format("Load symbols finished at %sms", timer.elapsed(TimeUnit.MILLISECONDS)));
         // For now, assuming not used for libraries and setting final access for fields.
         fullSymbolValues.writeClassesTo(
-            libSymbolMap, null, classOutPath, true, /* rPackageId= */ null);
+            libSymbolMap, null, classOutPath, classFiles, true, /* rPackageId= */ null);
         logger.fine(
             String.format("Finished R.class at %sms", timer.elapsed(TimeUnit.MILLISECONDS)));
-      } else {
-        Files.createDirectories(classOutPath);
       }
-      // We write .class files to temp, then jar them up after (we create a dummy jar, even if
-      // there are no class files).
+      // We create a dummy jar, even if there are no class files.
       AndroidResourceOutputs.createClassJar(
-          classOutPath, options.classJarOutput, options.targetLabel, options.injectingRuleKind);
+          classOutPath,
+          classFiles,
+          options.classJarOutput,
+          options.targetLabel,
+          options.injectingRuleKind);
       logger.fine(
           String.format("createClassJar finished at %sms", timer.elapsed(TimeUnit.MILLISECONDS)));
     } finally {
diff --git a/src/tools/java/com/google/devtools/build/android/resources/FieldInitializers.java b/src/tools/java/com/google/devtools/build/android/resources/FieldInitializers.java
index 3901521..7fae764 100644
--- a/src/tools/java/com/google/devtools/build/android/resources/FieldInitializers.java
+++ b/src/tools/java/com/google/devtools/build/android/resources/FieldInitializers.java
@@ -17,10 +17,10 @@
 import com.google.common.base.MoreObjects;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableListMultimap;
-import com.google.common.collect.Multimap;
-import com.google.common.collect.MultimapBuilder;
-import com.google.common.collect.SortedSetMultimap;
+import com.google.common.collect.Maps;
+import java.util.BitSet;
 import java.util.Collection;
+import java.util.Collections;
 import java.util.EnumMap;
 import java.util.Iterator;
 import java.util.Map;
@@ -38,6 +38,9 @@
 
   private final Map<ResourceType, Collection<FieldInitializer>> initializers;
 
+  /** Lazily computed by {@link #fieldIndex}. */
+  private Map<ResourceType, TypeIndex> fieldIndex;
+
   private FieldInitializers(Map<ResourceType, Collection<FieldInitializer>> initializers) {
     this.initializers = initializers;
   }
@@ -81,28 +84,80 @@
   }
 
   public FieldInitializers filter(FieldInitializers fieldsToWrite) {
-    // Create a map to filter with.
-    final SortedSetMultimap<ResourceType, String> symbolsToWrite =
-        MultimapBuilder.enumKeys(ResourceType.class).treeSetValues().build();
-    for (Map.Entry<ResourceType, Collection<FieldInitializer>> entry :
-        fieldsToWrite.initializers.entrySet()) {
-      for (FieldInitializer initializer : entry.getValue()) {
-        symbolsToWrite.put(entry.getKey(), initializer.getFieldName());
-      }
-    }
+    return filter(ImmutableList.of(fieldsToWrite));
+  }
 
-    final Multimap<ResourceType, FieldInitializer> initializersToWrite =
-        MultimapBuilder.enumKeys(ResourceType.class).arrayListValues().build();
-    for (Map.Entry<ResourceType, Collection<String>> entry : symbolsToWrite.asMap().entrySet()) {
-      // Resource type may be missing if resource overriding eliminates resources at the binary
-      // level, which were originally present at the library level.
-      for (FieldInitializer field : initializers.getOrDefault(entry.getKey(), ImmutableList.of())) {
-        if (entry.getValue().contains(field.getFieldName())) {
-          initializersToWrite.put(entry.getKey(), field);
+  /**
+   * Returns the subset of this instance's fields whose names appear in any of {@code
+   * fieldsToWrite}, in this instance's order.
+   */
+  public FieldInitializers filter(Collection<FieldInitializers> fieldsToWrite) {
+    Map<ResourceType, TypeIndex> index = fieldIndex();
+    Map<ResourceType, BitSet> selectedByType = new EnumMap<>(ResourceType.class);
+    for (FieldInitializers toWrite : fieldsToWrite) {
+      for (Map.Entry<ResourceType, Collection<FieldInitializer>> entry :
+          toWrite.initializers.entrySet()) {
+        // Resource type may be missing if resource overriding eliminates resources at the binary
+        // level, which were originally present at the library level.
+        TypeIndex typeIndex = index.get(entry.getKey());
+        if (typeIndex == null) {
+          continue;
+        }
+        BitSet selected = selectedByType.computeIfAbsent(entry.getKey(), k -> new BitSet());
+        for (FieldInitializer initializer : entry.getValue()) {
+          Integer position = typeIndex.positions.get(initializer.getFieldName());
+          if (position != null) {
+            selected.set(position);
+          }
         }
       }
     }
-    return copyOf(initializersToWrite.asMap());
+    final Map<ResourceType, Collection<FieldInitializer>> initializersToWrite =
+        new EnumMap<>(ResourceType.class);
+    for (Map.Entry<ResourceType, BitSet> entry : selectedByType.entrySet()) {
+      BitSet selected = entry.getValue();
+      if (selected.isEmpty()) {
+        continue;
+      }
+      ImmutableList<FieldInitializer> fields = index.get(entry.getKey()).fields;
+      ImmutableList.Builder<FieldInitializer> builder =
+          ImmutableList.builderWithExpectedSize(selected.cardinality());
+      for (int i = selected.nextSetBit(0); i >= 0; i = selected.nextSetBit(i + 1)) {
+        builder.add(fields.get(i));
+      }
+      initializersToWrite.put(entry.getKey(), builder.build());
+    }
+    return new FieldInitializers(Collections.unmodifiableMap(initializersToWrite));
+  }
+
+  /** The fields of one resource type, with an index from field name to position. */
+  private static final class TypeIndex {
+    final ImmutableList<FieldInitializer> fields;
+    final Map<String, Integer> positions;
+
+    TypeIndex(Collection<FieldInitializer> fields) {
+      this.fields = ImmutableList.copyOf(fields);
+      this.positions = Maps.newHashMapWithExpectedSize(this.fields.size());
+      for (int i = 0; i < this.fields.size(); i++) {
+        positions.putIfAbsent(this.fields.get(i).getFieldName(), i);
+      }
+    }
+  }
+
+  /**
+   * Returns a {@link TypeIndex} for each resource type. Built lazily, as it is only needed for
+   * {@link #filter}. Field names are expected to be unique within a type (e.g. symbol tables loaded
+   * from R.txt are deduped by name).
+   */
+  private synchronized Map<ResourceType, TypeIndex> fieldIndex() {
+    if (fieldIndex == null) {
+      Map<ResourceType, TypeIndex> index = new EnumMap<>(ResourceType.class);
+      for (Map.Entry<ResourceType, Collection<FieldInitializer>> entry : initializers.entrySet()) {
+        index.put(entry.getKey(), new TypeIndex(entry.getValue()));
+      }
+      fieldIndex = index;
+    }
+    return fieldIndex;
   }
 
   @Override
diff --git a/src/tools/java/com/google/devtools/build/android/resources/JavaIdentifierValidator.java b/src/tools/java/com/google/devtools/build/android/resources/JavaIdentifierValidator.java
index c377981..cc93bf6 100644
--- a/src/tools/java/com/google/devtools/build/android/resources/JavaIdentifierValidator.java
+++ b/src/tools/java/com/google/devtools/build/android/resources/JavaIdentifierValidator.java
@@ -43,7 +43,7 @@
    * @throws InvalidJavaIdentifier if the identifier is invalid.
    */
   public static String validate(String identifier, Object... additionalInformation) {
-    if (VALID_JAVA_IDENTIFIER.test(identifier)) {
+    if (isSimpleIdentifier(identifier) || VALID_JAVA_IDENTIFIER.test(identifier)) {
       return identifier;
     }
     throw new InvalidJavaIdentifier(
@@ -113,4 +113,27 @@
       ((Predicate<String>) JAVA_RESERVED::contains)
           .negate()
           .and(Pattern.compile("^([a-zA-Z_$][a-zA-Z\\d_$]*)$").asPredicate());
+
+  /**
+   * Fast check that accepts a subset of {@link #VALID_JAVA_IDENTIFIER}: non-reserved words matching
+   * {@code [a-zA-Z_$][a-zA-Z0-9_$]*}.
+   */
+  private static boolean isSimpleIdentifier(String identifier) {
+    if (identifier.isEmpty()) {
+      return false;
+    }
+    for (int i = 0; i < identifier.length(); i++) {
+      char c = identifier.charAt(i);
+      boolean valid =
+          (c >= 'a' && c <= 'z')
+              || (c >= 'A' && c <= 'Z')
+              || c == '_'
+              || c == '$'
+              || (i > 0 && c >= '0' && c <= '9');
+      if (!valid) {
+        return false;
+      }
+    }
+    return !JAVA_RESERVED.contains(identifier);
+  }
 }
diff --git a/src/tools/java/com/google/devtools/build/android/resources/RClassGenerator.java b/src/tools/java/com/google/devtools/build/android/resources/RClassGenerator.java
index 220001a..0308ad3 100644
--- a/src/tools/java/com/google/devtools/build/android/resources/RClassGenerator.java
+++ b/src/tools/java/com/google/devtools/build/android/resources/RClassGenerator.java
@@ -13,22 +13,20 @@
 // limitations under the License.
 package com.google.devtools.build.android.resources;
 
-import static java.nio.file.StandardOpenOption.CREATE_NEW;
-
 import com.android.SdkConstants;
 import com.android.resources.ResourceType;
-import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Preconditions;
 import com.google.common.base.Splitter;
 import com.google.common.collect.Iterables;
 import java.io.IOException;
-import java.nio.file.Files;
+import java.nio.file.FileAlreadyExistsException;
 import java.nio.file.Path;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.List;
 import java.util.ListIterator;
 import java.util.Map;
+import java.util.concurrent.ConcurrentMap;
 import org.objectweb.asm.AnnotationVisitor;
 import org.objectweb.asm.ClassWriter;
 import org.objectweb.asm.MethodVisitor;
@@ -61,45 +59,40 @@
   private final RPackageId rPackageId;
   private boolean rPackageWritten = false;
 
+  /** Generated class files, keyed by the path under {@code outFolder} they belong at. */
+  private final ConcurrentMap<Path, byte[]> classFiles;
+
   private static final Splitter PACKAGE_SPLITTER = Splitter.on('.');
 
   /**
-   * Create an RClassGenerator given a collection of initializers.
+   * Create an RClassGenerator given a collection of initializers. Class files are not written to
+   * disk; they are stored in {@code classFiles}, keyed by their path under {@code outFolder}.
    *
    * @param label Bazel target which owns the generated R class
-   * @param outFolder base folder to place the output R class files.
+   * @param outFolder base folder that the class file paths are relative to
+   * @param classFiles map that receives the generated class files
    * @param initializers the list of initializers to use for each inner class
    * @param finalFields true if the fields should be marked final
    * @param annotateTransitiveFields whether the R class and fields from transitive dependencies
    *     should be annotated.
    * @param rPackageId if provided fields values will be generated using RPackage class.
    */
-  public static RClassGenerator with(
+  public static RClassGenerator inMemory(
       String label,
       Path outFolder,
+      ConcurrentMap<Path, byte[]> classFiles,
       FieldInitializers initializers,
       boolean finalFields,
       boolean annotateTransitiveFields,
       RPackageId rPackageId) {
     return new RClassGenerator(
-        label, outFolder, initializers, finalFields, annotateTransitiveFields, rPackageId);
-  }
-
-  @VisibleForTesting
-  static RClassGenerator with(Path outFolder, FieldInitializers initializers, boolean finalFields) {
-    return with(outFolder, initializers, finalFields, /* rPackageId= */ null);
-  }
-
-  @VisibleForTesting
-  static RClassGenerator with(
-      Path outFolder, FieldInitializers initializers, boolean finalFields, RPackageId rPackageId) {
-    return new RClassGenerator(
-        /* label= */ null,
+        label,
         outFolder,
         initializers,
         finalFields,
-        /* annotateTransitiveFields= */ false,
-        rPackageId);
+        annotateTransitiveFields,
+        rPackageId,
+        classFiles);
   }
 
   private RClassGenerator(
@@ -108,13 +101,15 @@
       FieldInitializers initializers,
       boolean finalFields,
       boolean annotateTransitiveFields,
-      RPackageId rPackageId) {
+      RPackageId rPackageId,
+      ConcurrentMap<Path, byte[]> classFiles) {
     this.label = label;
     this.outFolder = outFolder;
     this.initializers = initializers;
     this.finalFields = finalFields;
     this.annotateTransitiveFields = annotateTransitiveFields;
     this.rPackageId = rPackageId;
+    this.classFiles = classFiles;
   }
 
   /**
@@ -125,6 +120,15 @@
     writeClasses(packageName, initializers.filter(symbolsToWrite));
   }
 
+  /**
+   * Builds bytecode and writes out R.class file, and R$inner.class files for provided package and
+   * the union of the provided symbols.
+   */
+  public void write(String packageName, Collection<FieldInitializers> symbolsToWrite)
+      throws IOException {
+    writeClasses(packageName, initializers.filter(symbolsToWrite));
+  }
+
   /** Builds bytecode and writes out R.class file, and R$inner.class files for provided package. */
   public void write(String packageName) throws IOException {
     writeClasses(packageName, initializers);
@@ -140,10 +144,8 @@
     for (String folder : folders) {
       packageDir = packageDir.resolve(folder);
     }
-    // At least create the outFolder that was requested. However, if there are no symbols, don't
-    // create the R.class and inner class files (no need to have an empty class).
-    Files.createDirectories(packageDir);
-
+    // If there are no symbols, don't create the R.class and inner class files (no need to have an
+    // empty class).
     if (Iterables.isEmpty(initializersToWrite)) {
       return;
     }
@@ -178,7 +180,7 @@
           Opcodes.ACC_PUBLIC | Opcodes.ACC_FINAL | Opcodes.ACC_STATIC);
     }
     classWriter.visitEnd();
-    Files.write(rClassFile, classWriter.toByteArray(), CREATE_NEW);
+    writeClassFile(rClassFile, classWriter.toByteArray());
     // Now generate the R$inner.class files.
     for (Map.Entry<ResourceType, Collection<FieldInitializer>> entry : initializersToWrite) {
       writeInnerClass(entry.getValue(), packageDir, rClassName, entry.getKey().toString());
@@ -211,7 +213,7 @@
 
     innerClassWriter.visitEnd();
     Path innerFile = packageDir.resolve("R$" + innerClass + ".class");
-    Files.write(innerFile, innerClassWriter.toByteArray(), CREATE_NEW);
+    writeClassFile(innerFile, innerClassWriter.toByteArray());
   }
 
   private String writeInnerClassHeader(
@@ -306,7 +308,7 @@
     }
   }
 
-  private void writeRPackageClassIfNeeded() throws IOException {
+  private synchronized void writeRPackageClassIfNeeded() throws IOException {
     if (rPackageId == null || rPackageWritten) {
       return;
     }
@@ -314,7 +316,6 @@
     final String rPackageClassName = rPackageId.getRPackageClassName();
     final String internalClassname = rPackageClassName.replace('.', '/');
     final Path outputPath = outFolder.resolve(internalClassname + ".class");
-    Files.createDirectories(outputPath.getParent());
 
     ClassWriter classWriter = new ClassWriter(ClassWriter.COMPUTE_MAXS);
     classWriter.visit(
@@ -334,8 +335,14 @@
         rPackageId.getPackageId());
 
     classWriter.visitEnd();
-    Files.write(outputPath, classWriter.toByteArray(), CREATE_NEW);
+    writeClassFile(outputPath, classWriter.toByteArray());
 
     rPackageWritten = true;
   }
+
+  private void writeClassFile(Path path, byte[] content) throws IOException {
+    if (classFiles.putIfAbsent(path, content) != null) {
+      throw new FileAlreadyExistsException(path.toString());
+    }
+  }
 }
diff --git a/src/tools/java/com/google/devtools/build/android/resources/ResourceSymbols.java b/src/tools/java/com/google/devtools/build/android/resources/ResourceSymbols.java
index 7ac6fad..1f56f58 100644
--- a/src/tools/java/com/google/devtools/build/android/resources/ResourceSymbols.java
+++ b/src/tools/java/com/google/devtools/build/android/resources/ResourceSymbols.java
@@ -13,31 +13,51 @@
 // limitations under the License.
 package com.google.devtools.build.android.resources;
 
-import com.android.builder.core.DefaultManifestParser;
+import static com.google.common.collect.ImmutableList.toImmutableList;
+
+import com.android.SdkConstants;
 import com.android.manifmerger.ManifestProvider;
+import com.android.manifmerger.PlaceholderHandler;
 import com.android.resources.ResourceType;
+import com.android.utils.XmlUtils;
+import com.google.common.base.Strings;
+import com.google.common.base.Throwables;
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.LinkedHashMultimap;
 import com.google.common.collect.Multimap;
 import com.google.common.util.concurrent.ListenableFuture;
 import com.google.common.util.concurrent.ListeningExecutorService;
+import com.google.common.util.concurrent.Uninterruptibles;
 import com.google.devtools.build.android.DependencyInfo;
 import java.io.File;
+import java.io.FileNotFoundException;
 import java.io.IOException;
 import java.nio.charset.StandardCharsets;
 import java.nio.file.Files;
 import java.nio.file.Path;
 import java.util.ArrayList;
 import java.util.Collection;
+import java.util.Comparator;
+import java.util.EnumMap;
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Objects;
-import java.util.TreeMap;
 import java.util.concurrent.Callable;
+import java.util.concurrent.ConcurrentMap;
 import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
 import java.util.logging.Logger;
 import javax.annotation.Nullable;
+import javax.xml.parsers.ParserConfigurationException;
+import javax.xml.parsers.SAXParser;
+import javax.xml.parsers.SAXParserFactory;
+import org.xml.sax.Attributes;
+import org.xml.sax.SAXException;
+import org.xml.sax.helpers.DefaultHandler;
 
 /** Encapsulates the logic for loading and writing resource symbols. */
 public class ResourceSymbols {
@@ -56,9 +76,10 @@
     public ResourceSymbols call() throws Exception {
       List<String> lines = Files.readAllLines(rTxtSymbols, StandardCharsets.UTF_8);
 
-      // NB: the inner map is working around a bug in R.txt generation!
+      // NB: deduping by field name is working around a bug in R.txt generation!
       // TODO(b/140643407): read directly without having to dedup by field name
-      final Map<ResourceType, Map<String, FieldInitializer>> initializers = new TreeMap<>();
+      final Map<ResourceType, List<FieldInitializer>> initializers =
+          new EnumMap<>(ResourceType.class);
 
       for (int lineIndex = 1; lineIndex <= lines.size(); lineIndex++) {
         String line = null;
@@ -86,8 +107,8 @@
           }
 
           initializers
-              .computeIfAbsent(ResourceTypeEnum.get(className), k -> new TreeMap<>())
-              .put(name, initializer);
+              .computeIfAbsent(ResourceTypeEnum.get(className), k -> new ArrayList<>())
+              .add(initializer);
         } catch (IndexOutOfBoundsException e) {
           String s =
               String.format(
@@ -98,31 +119,92 @@
         }
       }
 
-      return ResourceSymbols.from(
-          FieldInitializers.copyOf(
-              initializers.entrySet().stream()
-                  .collect(
-                      ImmutableMap.toImmutableMap(
-                          Map.Entry::getKey, entry -> entry.getValue().values()))));
+      ImmutableMap.Builder<ResourceType, Collection<FieldInitializer>> sortedInitializers =
+          ImmutableMap.builderWithExpectedSize(initializers.size());
+      for (Map.Entry<ResourceType, List<FieldInitializer>> entry : initializers.entrySet()) {
+        sortedInitializers.put(entry.getKey(), sortAndDedupeByName(entry.getValue()));
+      }
+      return ResourceSymbols.from(FieldInitializers.copyOf(sortedInitializers.buildOrThrow()));
+    }
+
+    /**
+     * Sorts by field name, keeping only the last occurrence of each name. Equivalent to inserting
+     * all fields into a {@link java.util.TreeMap} keyed by name, but cheaper since R.txt files are
+     * typically already sorted.
+     */
+    private static ImmutableList<FieldInitializer> sortAndDedupeByName(
+        List<FieldInitializer> fields) {
+      // List.sort is stable, so duplicates stay in encounter order.
+      fields.sort(Comparator.comparing(FieldInitializer::getFieldName));
+      ImmutableList.Builder<FieldInitializer> result =
+          ImmutableList.builderWithExpectedSize(fields.size());
+      for (int i = 0; i < fields.size(); i++) {
+        if (i + 1 == fields.size()
+            || !fields.get(i).getFieldName().equals(fields.get(i + 1).getFieldName())) {
+          result.add(fields.get(i));
+        }
+      }
+      return result.build();
     }
   }
 
   private static final class PackageParsingTask implements Callable<String> {
 
+    private static final SAXParserFactory PARSER_FACTORY =
+        XmlUtils.configureSaxFactory(
+            SAXParserFactory.newInstance(), /* namespaceAware= */ true, /* checkDtd= */ false);
+
+    /** SAX parsers are not thread-safe, but can be reused for sequential parses. */
+    private static final ThreadLocal<SAXParser> PARSER =
+        ThreadLocal.withInitial(
+            () -> {
+              try {
+                synchronized (PARSER_FACTORY) {
+                  return XmlUtils.createSaxParser(PARSER_FACTORY);
+                }
+              } catch (ParserConfigurationException | SAXException e) {
+                throw new IllegalStateException(e);
+              }
+            });
+
     private final File manifest;
 
     PackageParsingTask(File manifest) {
       this.manifest = manifest;
     }
 
+    /**
+     * Equivalent to {@code DefaultManifestParser.getPackage()}, which serializes all parsing
+     * behind a global lock and creates a new parser for each file.
+     */
     @Override
-    public String call() throws Exception {
-      return new DefaultManifestParser(
-              manifest,
-              /* canParseManifest= */ () -> true,
-              /* isManifestFileRequired= */ true,
-              /* issueReporter= */ null)
-          .getPackage();
+    public String call() throws IOException, SAXException {
+      if (!manifest.isFile()) {
+        throw new FileNotFoundException(
+            "Manifest file does not exist: " + manifest.getAbsolutePath());
+      }
+      String[] packageName = new String[1];
+      DefaultHandler handler =
+          new DefaultHandler() {
+            @Override
+            public void startElement(
+                String uri, String localName, String qName, Attributes attributes) {
+              if (Strings.isNullOrEmpty(uri) && localName.equals(SdkConstants.TAG_MANIFEST)) {
+                String value = attributes.getValue("", SdkConstants.ATTR_PACKAGE);
+                if (value != null && !PlaceholderHandler.isPlaceHolder(value)) {
+                  packageName[0] = value;
+                }
+              }
+            }
+          };
+      SAXParser parser = PARSER.get();
+      try {
+        parser.parse(manifest, handler);
+      } catch (IOException | SAXException | RuntimeException e) {
+        PARSER.remove();
+        throw e;
+      }
+      return packageName[0];
     }
   }
 
@@ -213,28 +295,64 @@
     return values;
   }
 
+  /**
+   * Generates the R classes for the given packages, storing them in {@code classFiles} keyed by
+   * their path under {@code classesOut}.
+   */
   public void writeClassesTo(
       Multimap<String, ResourceSymbols> libMap,
       String appPackageName,
       Path classesOut,
+      ConcurrentMap<Path, byte[]> classFiles,
       boolean finalFields,
       RPackageId rPackageId)
       throws IOException {
     RClassGenerator classWriter =
-        RClassGenerator.with(
+        RClassGenerator.inMemory(
             /* label= */ null,
             classesOut,
+            classFiles,
             values,
             finalFields,
             /* annotateTransitiveFields= */ false,
             rPackageId);
-    for (String packageName : libMap.keySet()) {
-      classWriter.write(packageName, ResourceSymbols.merge(libMap.get(packageName)).values);
-    }
-    if (appPackageName != null) {
-      // Unlike the R.java generation, we also write the app's R.class file so that the class
-      // jar file can be complete (aapt doesn't generate it for us).
-      classWriter.write(appPackageName);
+    // Packages are independent, so they can be written in parallel.
+    ExecutorService executor =
+        Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors());
+    try {
+      List<Future<?>> futures = new ArrayList<>();
+      for (Map.Entry<String, Collection<ResourceSymbols>> entry : libMap.asMap().entrySet()) {
+        String packageName = entry.getKey();
+        ImmutableList<FieldInitializers> packageInitializers =
+            entry.getValue().stream()
+                .map(ResourceSymbols::asInitializers)
+                .collect(toImmutableList());
+        futures.add(
+            executor.submit(
+                () -> {
+                  classWriter.write(packageName, packageInitializers);
+                  return null;
+                }));
+      }
+      if (appPackageName != null) {
+        // Unlike the R.java generation, we also write the app's R.class file so that the class
+        // jar file can be complete (aapt doesn't generate it for us).
+        futures.add(
+            executor.submit(
+                () -> {
+                  classWriter.write(appPackageName);
+                  return null;
+                }));
+      }
+      for (Future<?> future : futures) {
+        Uninterruptibles.getUninterruptibly(future);
+      }
+    } catch (ExecutionException e) {
+      Throwables.throwIfInstanceOf(e.getCause(), IOException.class);
+      Throwables.throwIfUnchecked(e.getCause());
+      throw new IllegalStateException(e.getCause());
+    } finally {
+      executor.shutdownNow();
     }
   }
 }
diff --git a/src/tools/javatests/com/google/devtools/build/android/AndroidResourceClassWriterTest.java b/src/tools/javatests/com/google/devtools/build/android/AndroidResourceClassWriterTest.java
index 6ce233f..17721e1 100644
--- a/src/tools/javatests/com/google/devtools/build/android/AndroidResourceClassWriterTest.java
+++ b/src/tools/javatests/com/google/devtools/build/android/AndroidResourceClassWriterTest.java
@@ -31,11 +31,14 @@
 import java.util.logging.Handler;
 import java.util.logging.LogRecord;
 import java.util.logging.Logger;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipInputStream;
 import org.junit.After;
 import org.junit.Before;
 import org.junit.Rule;
 import org.junit.Test;
 import org.junit.rules.ExpectedException;
+import org.junit.rules.TemporaryFolder;
 import org.junit.runner.RunWith;
 import org.junit.runners.JUnit4;
 
@@ -47,6 +50,7 @@
   private TestLoggingHandler loggingHandler;
 
   @Rule public final ExpectedException thrown = ExpectedException.none();
+  @Rule public final TemporaryFolder tmp = new TemporaryFolder();
 
   private static final AndroidFrameworkAttrIdProvider mockAndroidFrameworkIds =
       new MockAndroidFrameworkAttrIdProvider(ImmutableMap.<String, Integer>of());
@@ -90,6 +94,17 @@
         UnwrittenMergedAndroidData.of(
             source.resolve("AndroidManifest.xml"), direct, ParsedAndroidDataBuilder.empty());
     unwrittenMergedAndroidData.writeResourceClass(resourceClassWriter);
+    // Class files are only written to the jar.
+    assertThat(Files.exists(target.resolve("com/carroll/lewis/R.class"))).isFalse();
+    Path classJar = writeClassJar(resourceClassWriter, target);
+    assertThat(jarEntryNames(classJar))
+        .containsExactly(
+            "META-INF/",
+            "META-INF/MANIFEST.MF",
+            "com/carroll/lewis/R$id.class",
+            "com/carroll/lewis/R$layout.class",
+            "com/carroll/lewis/R.class")
+        .inOrder();
 
     assertAbout(paths)
         .that(target.resolve("com/carroll/lewis/R.java"))
@@ -105,7 +120,7 @@
             "}",
             "}");
     assertAbout(paths)
-        .that(target)
+        .that(classJar)
         .withClass("com.carroll.lewis.R$id")
         .classContentsIsEqualTo(
             ImmutableMap.of(
@@ -114,7 +129,7 @@
             ImmutableMap.<String, List<Integer>>of(),
             false);
     assertAbout(paths)
-        .that(target)
+        .that(classJar)
         .withClass("com.carroll.lewis.R$layout")
         .classContentsIsEqualTo(
             ImmutableMap.of("some_layout", 0x7f020000),
@@ -140,6 +155,7 @@
         UnwrittenMergedAndroidData.of(
             source.resolve("AndroidManifest.xml"), direct, ParsedAndroidDataBuilder.empty());
     unwrittenMergedAndroidData.writeResourceClass(resourceClassWriter);
+    Path classJar = writeClassJar(resourceClassWriter, target);
     assertAbout(paths)
         .that(target.resolve("com/boop/R.java"))
         .javaContentsIsEqualTo(
@@ -151,7 +167,7 @@
             "}",
             "}");
     assertAbout(paths)
-        .that(target)
+        .that(classJar)
         .withClass("com.boop.R$drawable")
         .classContentsIsEqualTo(
             ImmutableMap.of(
@@ -186,6 +202,7 @@
         UnwrittenMergedAndroidData.of(
             source.resolve("AndroidManifest.xml"), direct, ParsedAndroidDataBuilder.empty());
     unwrittenMergedAndroidData.writeResourceClass(resourceClassWriter);
+    Path classJar = writeClassJar(resourceClassWriter, target);
     assertAbout(paths)
         .that(target.resolve("com/boop/R.java"))
         .javaContentsIsEqualTo(
@@ -199,7 +216,7 @@
             "}",
             "}");
     assertAbout(paths)
-        .that(target)
+        .that(classJar)
         .withClass("com.boop.R$drawable")
         .classContentsIsEqualTo(
             ImmutableMap.of(
@@ -268,6 +285,7 @@
     UnwrittenMergedAndroidData unwrittenMergedAndroidData =
         UnwrittenMergedAndroidData.of(source.resolve("AndroidManifest.xml"), direct, transitiveDep);
     unwrittenMergedAndroidData.writeResourceClass(resourceClassWriter);
+    Path classJar = writeClassJar(resourceClassWriter, target);
 
     assertAbout(paths)
         .that(target.resolve("com/carroll/lewis/R.java"))
@@ -296,7 +314,7 @@
             "}",
             "}");
     assertAbout(paths)
-        .that(target)
+        .that(classJar)
         .withClass("com.carroll.lewis.R$attr")
         .classContentsIsEqualTo(
             ImmutableMap.of(
@@ -306,7 +324,7 @@
             ImmutableMap.<String, List<Integer>>of(),
             false);
     assertAbout(paths)
-        .that(target)
+        .that(classJar)
         .withClass("com.carroll.lewis.R$style")
         .classContentsIsEqualTo(
             ImmutableMap.of(
@@ -315,7 +333,7 @@
             ImmutableMap.<String, List<Integer>>of(),
             false);
     assertAbout(paths)
-        .that(target)
+        .that(classJar)
         .withClass("com.carroll.lewis.R$styleable")
         .classContentsIsEqualTo(
             ImmutableMap.<String, Integer>builder()
@@ -381,6 +399,7 @@
         UnwrittenMergedAndroidData.of(
             source.resolve("AndroidManifest.xml"), direct, ParsedAndroidDataBuilder.empty());
     unwrittenMergedAndroidData.writeResourceClass(resourceClassWriter);
+    Path classJar = writeClassJar(resourceClassWriter, target);
 
     assertAbout(paths)
         .that(target.resolve("com/carroll/lewis/R.java"))
@@ -404,7 +423,7 @@
             "}",
             "}");
     assertAbout(paths)
-        .that(target)
+        .that(classJar)
         .withClass("com.carroll.lewis.R$attr")
         .classContentsIsEqualTo(
             ImmutableMap.of(
@@ -413,12 +432,12 @@
             ImmutableMap.<String, List<Integer>>of(),
             false);
     assertAbout(paths)
-        .that(target)
+        .that(classJar)
         .withClass("com.carroll.lewis.R$style")
         .classContentsIsEqualTo(
             ImmutableMap.of("YStyle", 0x7f020000), ImmutableMap.<String, List<Integer>>of(), false);
     assertAbout(paths)
-        .that(target)
+        .that(classJar)
         .withClass("com.carroll.lewis.R$styleable")
         .classContentsIsEqualTo(
             ImmutableMap.of(
@@ -598,6 +617,33 @@
         () -> unwrittenMergedAndroidData.writeResourceClass(resourceClassWriter));
   }
 
+  /**
+   * Writes the generated class files to a jar the same way {@link
+   * AndroidCompiledResourceMergingAction} does, and returns the jar. The jar is on the default file
+   * system so that classes can be loaded from it.
+   */
+  private Path writeClassJar(AndroidResourceClassWriter writer, Path classesRoot)
+      throws IOException {
+    Path classJar = tmp.newFolder().toPath().resolve("classes.jar");
+    AndroidResourceOutputs.createClassJar(
+        classesRoot,
+        writer.getClassFiles(),
+        classJar,
+        /* targetLabel= */ null,
+        /* injectingRuleKind= */ null);
+    return classJar;
+  }
+
+  private static ImmutableList<String> jarEntryNames(Path jar) throws IOException {
+    ImmutableList.Builder<String> names = ImmutableList.builder();
+    try (ZipInputStream zip = new ZipInputStream(Files.newInputStream(jar))) {
+      for (ZipEntry entry = zip.getNextEntry(); entry != null; entry = zip.getNextEntry()) {
+        names.add(entry.getName());
+      }
+    }
+    return names.build();
+  }
+
   private static class MockAndroidFrameworkAttrIdProvider
       implements AndroidFrameworkAttrIdProvider {
 
diff --git a/src/tools/javatests/com/google/devtools/build/android/resources/BUILD b/src/tools/javatests/com/google/devtools/build/android/resources/BUILD
index 58a32f4..2eab46f 100644
--- a/src/tools/javatests/com/google/devtools/build/android/resources/BUILD
+++ b/src/tools/javatests/com/google/devtools/build/android/resources/BUILD
@@ -22,6 +22,7 @@
         "@rules_android_maven//:com_google_guava_guava",
     ],
     deps = [
+        "//src/tools/java/com/google/devtools/build/android:android_builder_lib",
         "//src/tools/java/com/google/devtools/build/android:android_common_30_1_3",
         "//src/tools/java/com/google/devtools/build/android/resources",
         "@rules_android_maven//:com_google_guava_failureaccess",
diff --git a/src/tools/javatests/com/google/devtools/build/android/resources/RClassGeneratorTest.java b/src/tools/javatests/com/google/devtools/build/android/resources/RClassGeneratorTest.java
index 9980adb..794bec9 100644
--- a/src/tools/javatests/com/google/devtools/build/android/resources/RClassGeneratorTest.java
+++ b/src/tools/javatests/com/google/devtools/build/android/resources/RClassGeneratorTest.java
@@ -21,10 +21,10 @@
 import com.google.common.base.Joiner;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
-import com.google.common.collect.Iterables;
 import com.google.common.collect.Maps;
 import com.google.common.util.concurrent.ListeningExecutorService;
 import com.google.common.util.concurrent.MoreExecutors;
+import com.google.devtools.build.android.AndroidResourceOutputs;
 import com.google.devtools.build.android.resources.JavaIdentifierValidator.InvalidJavaIdentifier;
 import java.io.IOException;
 import java.lang.reflect.Field;
@@ -33,13 +33,13 @@
 import java.net.URL;
 import java.net.URLClassLoader;
 import java.nio.charset.StandardCharsets;
-import java.nio.file.DirectoryStream;
 import java.nio.file.FileAlreadyExistsException;
 import java.nio.file.Files;
 import java.nio.file.Path;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Map;
+import java.util.concurrent.ConcurrentSkipListMap;
 import java.util.concurrent.ExecutionException;
 import java.util.function.Consumer;
 import java.util.stream.IntStream;
@@ -58,6 +58,7 @@
 public class RClassGeneratorTest {
 
   private Path temp;
+  private final ConcurrentSkipListMap<Path, byte[]> classFiles = new ConcurrentSkipListMap<>();
   @Rule public final ExpectedException thrown = ExpectedException.none();
 
   @Before
@@ -98,11 +99,9 @@
     }
     ResourceSymbols symbolValues = createSymbolFile("R.txt", resourceFields.toArray(new String[0]));
     Path out = temp.resolve("classes");
-    Files.createDirectories(out);
     RPackageId rPackageId = withRPackage ? RPackageId.createFor("com.bar") : null;
     RClassGenerator writer =
-        RClassGenerator.with(
-            out, symbolValues.asInitializers(), /* finalFields= */ false, rPackageId);
+        generator(out, symbolValues.asInitializers(), /* finalFields= */ false, rPackageId);
 
     writer.write("com.bar", symbolValues.asInitializers());
 
@@ -139,11 +138,9 @@
     }
     ResourceSymbols symbolValues = createSymbolFile("R.txt", resourceFields.toArray(new String[0]));
     Path out = temp.resolve("classes");
-    Files.createDirectories(out);
     RPackageId rPackageId = withRPackage ? RPackageId.createFor("com.bar") : null;
     RClassGenerator writer =
-        RClassGenerator.with(
-            out, symbolValues.asInitializers(), /* finalFields= */ false, rPackageId);
+        generator(out, symbolValues.asInitializers(), /* finalFields= */ false, rPackageId);
 
     writer.write("com.bar", symbolValues.asInitializers());
 
@@ -167,9 +164,8 @@
         "int[] styleable " + fieldName + " { " + String.join(", ", arrayValue) + " }";
     ResourceSymbols symbolValues = createSymbolFile("R.txt", resourceField);
     Path out = temp.resolve("classes");
-    Files.createDirectories(out);
     RClassGenerator writer =
-        RClassGenerator.with(out, symbolValues.asInitializers(), /*finalFields=*/ false);
+        generator(out, symbolValues.asInitializers(), /* finalFields= */ false);
 
     thrown.expect(IllegalStateException.class);
 
@@ -200,8 +196,7 @@
             "int string ok 0x1",
             "int string largePackageId 0x1");
     Path out = temp.resolve("classes");
-    Files.createDirectories(out);
-    RClassGenerator writer = RClassGenerator.with(out, symbolValues.asInitializers(), finalFields);
+    RClassGenerator writer = generator(out, symbolValues.asInitializers(), finalFields);
     writer.write("com.bar", symbolsInLibrary.asInitializers());
 
     Path packageDir = out.resolve("com/bar");
@@ -244,12 +239,10 @@
     Path out = temp.resolve("classes");
     String packageName = "com";
     Path packageFolder = out.resolve(packageName);
-    Files.createDirectories(packageFolder);
 
     RPackageId rPackageId = withRPackage ? RPackageId.createFor(packageName) : null;
-    RClassGenerator writer =
-        RClassGenerator.with(out, symbolValues.asInitializers(), false, rPackageId);
-    Files.write(packageFolder.resolve(existingFile), new byte[0]);
+    RClassGenerator writer = generator(out, symbolValues.asInitializers(), false, rPackageId);
+    classFiles.put(packageFolder.resolve(existingFile), new byte[0]);
 
     try {
       writer.write(packageName, symbolsInLibrary.asInitializers());
@@ -278,8 +271,7 @@
             "R.txt", "int[] styleable ActionMenuView { }", "int[] styleable ActionMenuView2 {  }");
     ResourceSymbols symbolsInLibrary = symbolValues;
     Path out = temp.resolve("classes");
-    Files.createDirectories(out);
-    RClassGenerator writer = RClassGenerator.with(out, symbolValues.asInitializers(), finalFields);
+    RClassGenerator writer = generator(out, symbolValues.asInitializers(), finalFields);
     writer.write("com.testEmptyIntArray", symbolsInLibrary.asInitializers());
 
     Path packageDir = out.resolve("com/testEmptyIntArray");
@@ -347,9 +339,9 @@
     Path path = createFile("R.txt", "int id 42ActionMenuView 0x7f020000");
     final ResourceSymbols resourceSymbols =
         ResourceSymbols.load(path, MoreExecutors.newDirectExecutorService()).get();
-    Path out = Files.createDirectories(temp.resolve("classes"));
+    Path out = temp.resolve("classes");
     thrown.expect(INVALID_JAVA_IDENTIFIER);
-    RClassGenerator.with(out, resourceSymbols.asInitializers(), true).write("somepackage");
+    generator(out, resourceSymbols.asInitializers(), true).write("somepackage");
   }
 
   @Test
@@ -357,9 +349,9 @@
     Path path = createFile("R.txt", "int id Action:MenuView 0x7f020000");
     final ResourceSymbols resourceSymbols =
         ResourceSymbols.load(path, MoreExecutors.newDirectExecutorService()).get();
-    Path out = Files.createDirectories(temp.resolve("classes"));
+    Path out = temp.resolve("classes");
     thrown.expect(INVALID_JAVA_IDENTIFIER);
-    RClassGenerator.with(out, resourceSymbols.asInitializers(), true).write("somepackage");
+    generator(out, resourceSymbols.asInitializers(), true).write("somepackage");
   }
 
   @Test
@@ -367,9 +359,9 @@
     Path path = createFile("R.txt", "int id package 0x7f020000");
     final ResourceSymbols resourceSymbols =
         ResourceSymbols.load(path, MoreExecutors.newDirectExecutorService()).get();
-    Path out = Files.createDirectories(temp.resolve("classes"));
+    Path out = temp.resolve("classes");
     thrown.expect(INVALID_JAVA_IDENTIFIER);
-    RClassGenerator.with(out, resourceSymbols.asInitializers(), true).write("somepackage");
+    generator(out, resourceSymbols.asInitializers(), true).write("somepackage");
   }
 
   @Test
@@ -386,8 +378,7 @@
             "int id debug_text_field2 0x1",
             "int layout stubbable_activity 0x1");
     Path out = temp.resolve("classes");
-    Files.createDirectories(out);
-    RClassGenerator writer = RClassGenerator.with(out, symbolValues.asInitializers(), finalFields);
+    RClassGenerator writer = generator(out, symbolValues.asInitializers(), finalFields);
     writer.write("com.foo", symbolsInLibrary.asInitializers());
 
     Path packageDir = out.resolve("com/foo");
@@ -410,8 +401,7 @@
         createSymbolFile("R.txt", "int layout stubbable_activity 0x7f020000");
     ResourceSymbols symbolsInLibrary = createSymbolFile("lib.R.txt");
     Path out = temp.resolve("classes");
-    Files.createDirectories(out);
-    RClassGenerator writer = RClassGenerator.with(out, symbolValues.asInitializers(), finalFields);
+    RClassGenerator writer = generator(out, symbolValues.asInitializers(), finalFields);
     writer.write("com.foo", symbolsInLibrary.asInitializers());
 
     Path packageDir = out.resolve("com/foo");
@@ -457,8 +447,7 @@
             "int styleable ActionButton_zoo 8");
     ResourceSymbols symbolsInLibrary = symbolValues;
     Path out = temp.resolve("classes");
-    Files.createDirectories(out);
-    RClassGenerator writer = RClassGenerator.with(out, symbolValues.asInitializers(), finalFields);
+    RClassGenerator writer = generator(out, symbolValues.asInitializers(), finalFields);
     writer.write("com.intArray", symbolsInLibrary.asInitializers());
 
     Path packageDir = out.resolve("com/intArray");
@@ -518,8 +507,7 @@
     ResourceSymbols symbolValues = createSymbolFile("R.txt", "int string some_string 0x7f200000");
     ResourceSymbols symbolsInLibrary = symbolValues;
     Path out = temp.resolve("classes");
-    Files.createDirectories(out);
-    RClassGenerator writer = RClassGenerator.with(out, symbolValues.asInitializers(), finalFields);
+    RClassGenerator writer = generator(out, symbolValues.asInitializers(), finalFields);
     writer.write("", symbolsInLibrary.asInitializers());
 
     Path packageDir = out.resolve("");
@@ -572,10 +560,8 @@
             "int styleable StyleableName_attrName 2");
     ResourceSymbols symbolsInLibrary = createSymbolFile("lib.R.txt", "int id idName 0x1");
     Path out = temp.resolve("classes");
-    Files.createDirectories(out);
 
-    RClassGenerator writer =
-        RClassGenerator.with(out, symbolValues.asInitializers(), finalFields, rPackageId);
+    RClassGenerator writer = generator(out, symbolValues.asInitializers(), finalFields, rPackageId);
     writer.write("com.libraryRemapping", symbolsInLibrary.asInitializers());
     writer.write("com.remapping");
 
@@ -651,6 +637,23 @@
 
   // Test utilities
 
+  private RClassGenerator generator(
+      Path outFolder, FieldInitializers initializers, boolean finalFields) {
+    return generator(outFolder, initializers, finalFields, /* rPackageId= */ null);
+  }
+
+  private RClassGenerator generator(
+      Path outFolder, FieldInitializers initializers, boolean finalFields, RPackageId rPackageId) {
+    return RClassGenerator.inMemory(
+        /* label= */ null,
+        outFolder,
+        classFiles,
+        initializers,
+        finalFields,
+        /* annotateTransitiveFields= */ false,
+        rPackageId);
+  }
+
   private Path createFile(String name, String... contents) throws IOException {
     Path path = temp.resolve(name);
     Files.createDirectories(path.getParent());
@@ -693,26 +696,30 @@
     return id -> id - rPackageId.getPackageId() + newPackageId;
   }
 
-  private static void checkFilesInPackage(Path packageDir, String... expectedFiles)
-      throws IOException {
-    try (DirectoryStream<Path> stream = Files.newDirectoryStream(packageDir)) {
-      ImmutableList<String> filesInPackage =
-          ImmutableList.copyOf(
-              Iterables.transform(
-                  stream,
-                  new Function<Path, String>() {
-                    @Override
-                    public String apply(Path path) {
-                      return path.getFileName().toString();
-                    }
-                  }));
-      assertThat(filesInPackage).containsExactly((Object[]) expectedFiles);
-    }
+  /** Checks the names of the class files generated directly in {@code packageDir}. */
+  private void checkFilesInPackage(Path packageDir, String... expectedFiles) {
+    ImmutableList<String> filesInPackage =
+        classFiles.keySet().stream()
+            .filter(path -> packageDir.equals(path.getParent()))
+            .map(path -> path.getFileName().toString())
+            .collect(toImmutableList());
+    assertThat(filesInPackage).containsExactlyElementsIn(expectedFiles);
   }
 
-  private static Class<?> checkTopLevelClass(
+  /**
+   * Writes the generated class files to a jar, the same way the actions do, and returns a class
+   * loader for it.
+   */
+  private URLClassLoader classLoader(Path baseDir) throws IOException {
+    Path classJar = Files.createTempFile(temp, "classes", ".jar");
+    AndroidResourceOutputs.createClassJar(
+        baseDir, classFiles, classJar, /* targetLabel= */ null, /* injectingRuleKind= */ null);
+    return new URLClassLoader(new URL[] {classJar.toUri().toURL()}, getClass().getClassLoader());
+  }
+
+  private Class<?> checkTopLevelClass(
       Path baseDir, String expectedClassName, String... expectedInnerClasses) throws Exception {
-    try (URLClassLoader urlClassLoader = new URLClassLoader(new URL[] {baseDir.toUri().toURL()})) {
+    try (URLClassLoader urlClassLoader = classLoader(baseDir)) {
       Class<?> toplevelClass = urlClassLoader.loadClass(expectedClassName);
       assertThat(toplevelClass.getSuperclass()).isEqualTo(Object.class);
       int outerModifiers = toplevelClass.getModifiers();
@@ -759,8 +766,7 @@
       boolean areFieldsFinal,
       Consumer<URLClassLoader> beforeLoadClass)
       throws Exception {
-    try (URLClassLoader urlClassLoader =
-        new URLClassLoader(new URL[] {baseDir.toUri().toURL()}, getClass().getClassLoader())) {
+    try (URLClassLoader urlClassLoader = classLoader(baseDir)) {
       if (beforeLoadClass != null) {
         beforeLoadClass.accept(urlClassLoader);
       }
@@ -799,8 +805,7 @@
    * to initialize in the <clinit> alone.
    */
   private void checkStaticInitCreated(Path baseDir, String expectedClassName) throws Exception {
-    try (URLClassLoader urlClassLoader =
-        new URLClassLoader(new URL[] {baseDir.toUri().toURL()}, getClass().getClassLoader())) {
+    try (URLClassLoader urlClassLoader = classLoader(baseDir)) {
       Class<?> innerClass = urlClassLoader.loadClass(expectedClassName);
       try {
         Method unused = innerClass.getDeclaredMethod("staticInit0");
@@ -811,8 +816,7 @@
   }
 
   private void checkRPackageClass(Path baseDir, RPackageId rPackageId) throws Exception {
-    try (URLClassLoader urlClassLoader =
-        new URLClassLoader(new URL[] {baseDir.toUri().toURL()}, getClass().getClassLoader())) {
+    try (URLClassLoader urlClassLoader = classLoader(baseDir)) {
       Class<?> rPackageClass = urlClassLoader.loadClass(rPackageId.getRPackageClassName());
       assertThat(rPackageClass.getSuperclass()).isEqualTo(Object.class);
       assertThat(rPackageClass.getFields()).hasLength(1);