Understanding SPI in Android Componentization

Understanding SPI in Android Componentization

/ Today’s Tech News /

On December 5, 2023, at 07:33, Galaxy Dynamics Aerospace Company successfully launched the Ceres-1 (Remote 9) rocket from the Jiuquan Satellite Launch Center (Mission Code: WE WON’T STOP). It successfully placed two satellites, Tianyan-16 and Xingchi-1A, into a 500km twilight orbit. This marks the first successful twilight orbit launch by a domestic private rocket company, and the tenth successful launch of the Ceres-1 series commercial launch vehicle. As of now, Galaxy Dynamics has served 16 commercial satellite clients and successfully launched 35 different types of commercial satellites.

/ Author Bio /

This article is contributed by Master Fu’s Son-in-Law, sharing insights on SPI in Android. I believe it will be helpful for everyone! Thanks to the author for this wonderful article.

Visit Master Fu’s Son-in-Law‘s blog at:

https://juejin.cn/user/1943592289705661/posts

/ Introduction /

I previously wrote an article on APT (Annotation Processing Tool) where I implemented ButterKnife functionality using custom annotations. Recently, I encountered a new concept in a new project, which is SPI, and I decided to research it.

SPI, or Service Provider Interface, uses interfaces, configuration files, and the strategy pattern to achieve decoupling, modularization, and componentization.

/ Using ServiceLoader /

Define an interface class in the common or interface module: com.aya.demo.common.ILogInterface.java.

public interface ILogInterface{
    void logD(String msg);
}

In the actual business module, implement the interface: com.aya.demo.LogImpl.java.

public class LogImpl implements ILogInterface{
    @Override
    public void logD(String msg){
        Log.d("AYA", msg);
    }
}

In the business module, create a directory /META-INF/services under src/main/resources, and create a file named with the full path of the interface. The content of the file should be the full path of the interface implementation class. The file path is: src/main/resources/META-INF/services/com.aya.demo.common.ILogInterface. The content is:

com.aya.demo.LogImpl

Create a ServiceLoader utility class.

SPIUtils.java

 /**
     * If there are multiple implementations, return the first one
     */
    public static <T> T getSpiImpl(Class<T> t) {
        try {
            ServiceLoader<T> loader = ServiceLoader.load(t, CommonHolder.ctx.getClassLoader());
            Iterator<T> iter = loader.iterator();
            if (iter.hasNext()) {
                return iter.next();
            }
        } catch (Exception e) {
            //
        }
        return null;
    }
    /**
     * Returns all implementations
     */
    public static <T> List<T> getSpiImpls(Class<T> t) {
        List<T> impls = new ArrayList<>();
        try {
            ServiceLoader<T> loader = ServiceLoader.load(t, CommonHolder.ctx.getClassLoader());
            Iterator<T> iter = loader.iterator();
            while (iter.hasNext()) {
                impls.add(iter.next());
            }
        } catch (Exception e) {
            //
        }
        return impls;
    }

Using the interface class

ILogInterface iLog =  SPIUtils.getSpiImpl(ILogInterface.class);
iLog.logD("Testing");

Using SPI can effectively achieve decoupling and component isolation, but there is a drawback: every time a new implementation class is created, a file needs to be created or an existing file modified in the /META-INF/services directory, which can be cumbersome and prone to errors. Programmers will definitely find ways to handle this inconvenience, so Google provided us with the AutoService tool.

/ Using AutoService /

First, you need to introduce the dependency

Note: It is important to introduce this dependency; the module containing the interface class must include it entirely.

// Dependency for autoService library
implementation 'com.google.auto.service:auto-service:1.0'
annotationProcessor 'com.google.auto.service:auto-service:1.0'

For the module containing the implementation class, only the annotation processor needs to be introduced:

// Dependency for autoService library
annotationProcessor 'com.google.auto.service:auto-service:1.0'

Note: If the implementation class is written in Kotlin, you need to use kapt when introducing dependencies:

apply plugin: 'kotlin-android'
apply plugin: 'kotlin-kapt'

dependencies {
    kapt 'com.google.auto.service:auto-service:1.0'
}

The original interface class remains unchanged; you only need to modify the implementation class by adding the @AutoService annotation, with the parameter being the class of the interface.

com.aya.demo.LogImpl.java

@AutoService(ILogInterface.class)
public class LogImpl implements ILogInterface{
    @Override
    public void logD(String msg){
        Log.d("AYA", msg);
    }
}

Write a ServiceLoader utility class SPIUtils.java, just like before. This way, you can use the interface as before.

/ AutoService Principle /

The principle of AutoService is to use custom annotations, with an annotation processor scanning the annotations at compile time and automatically generating the /resources/META-INF/… files.

The key class of the AutoService tool is AutoServiceProcessor.java. The key code is as follows, with some annotations added:

public class AutoServiceProcessor extends AbstractProcessor {

    ...
    // Multimap: key can be repeated
    private final Multimap<String, String> providers = HashMultimap.create();
    ...

      /**
       * <ol>
       *  <li> For each class annotated with {@link AutoService}<ul>
       *      <li> Verify the {@link AutoService} interface value is correct
       *      <li> Categorize the class by its service interface
       *      </ul>
       *
       *  <li> For each {@link AutoService} interface <ul>
       *       <li> Create a file named {@code META-INF/services/<interface>}
       *       <li> For each {@link AutoService} annotated class for this interface <ul>
       *           <li> Create an entry in the file
       *           </ul>
       *       </ul>
       * </ol>
       */
      @Override
      public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
      // This method is the entry point of the annotation processor
        try {
          processImpl(annotations, roundEnv);
        } catch (RuntimeException e) {
          // We don't allow exceptions of any kind to propagate to the compiler
          fatalError(getStackTraceAsString(e));
        }
        return false;
      }

      private void processImpl(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
        if (roundEnv.processingOver()) {
            // If processing is finished, generate registration files
            generateConfigFiles();
        } else {
            // Process annotations
            processAnnotations(annotations, roundEnv);
        }
      }

        // Process annotations
      private void processAnnotations(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
        // Get all classes annotated with AutoService
        Set<? extends Element> elements = roundEnv.getElementsAnnotatedWith(AutoService.class);

        log(annotations.toString());
        log(elements.toString());

        for (Element e : elements) {
          // TODO(gak): check for error trees?
          TypeElement providerImplementer = MoreElements.asType(e);
          // Get the AutoService annotation value, which is the interface specified when declaring the implementation class
          AnnotationMirror annotationMirror = getAnnotationMirror(e, AutoService.class).get();
          // Get the collection of values
          Set<DeclaredType> providerInterfaces = getValueFieldOfClasses(annotationMirror);
          if (providerInterfaces.isEmpty()) {
            // If the collection is empty, meaning no value specified, report an error and do not process
            error(MISSING_SERVICES_ERROR, e, annotationMirror);
            continue;
          }
          // Iterate through all values to get the full class name of the value
          for (DeclaredType providerInterface : providerInterfaces) {
            TypeElement providerType = MoreTypes.asTypeElement(providerInterface);

            log("provider interface: " + providerType.getQualifiedName());
            log("provider implementer: " + providerImplementer.getQualifiedName());

            // Check if it is an implementation of the interface; if so, store it in the Multimap type providers cache, where the key is the full path of the interface and the value is the full path of the implementation class; otherwise report an error
            if (checkImplementer(providerImplementer, providerType, annotationMirror)) {
              providers.put(getBinaryName(providerType), getBinaryName(providerImplementer));
            } else {
              String message = "ServiceProviders must implement their service provider interface. "
                  + providerImplementer.getQualifiedName() + " does not implement "
                  + providerType.getQualifiedName();
              error(message, e, annotationMirror);
            }
          }
        }
      }

        // Generate spi files
      private void generateConfigFiles() {
        Filer filer = processingEnv.getFiler();
        // Iterate over the keys of providers
        for (String providerInterface : providers.keySet()) {
          String resourceFile = "META-INF/services/" + providerInterface;
          log("Working on resource file: " + resourceFile);
          try {
            SortedSet<String> allServices = Sets.newTreeSet();
            try {
              // would like to be able to print the full path
              // before we attempt to get the resource in case the behavior
              // of filer.getResource does change to match the spec, but there's
              // no good way to resolve CLASS_OUTPUT without first getting a resource.
              // "META-INF/services/**" file already exists
              FileObject existingFile = filer.getResource(StandardLocation.CLASS_OUTPUT, "",
                  resourceFile);
              log("Looking for existing resource file at " + existingFile.toUri());
              // The service set in this SPI file
              Set<String> oldServices = ServicesFiles.readServiceFile(existingFile.openInputStream());
              log("Existing service entries: " + oldServices);
              // Write the services in the spi file to cache
              allServices.addAll(oldServices);
            } catch (IOException e) {
              // According to the javadoc, Filer.getResource throws an exception
              // if the file doesn't already exist. In practice, this doesn't
              // appear to be the case. Filer.getResource will happily return a
              // FileObject that refers to a non-existent file but will throw
              // IOException if you try to open an input stream for it.
              log("Resource file did not already exist.");
            }

            // Create the service set based on the providers cache
            Set<String> newServices = new HashSet<>(providers.get(providerInterface));
            // If all new ones already exist, just return without processing
            if (allServices.containsAll(newServices)) {
              log("No new service entries being added.");
              return;
            }

            // Add all new services to cache
            allServices.addAll(newServices);
            log("New service file contents: " + allServices);
            // Write all services to file
            FileObject fileObject = filer.createResource(StandardLocation.CLASS_OUTPUT, "",
                resourceFile);
            try (OutputStream out = fileObject.openOutputStream()) {
              ServicesFiles.writeServiceFile(allServices, out);
            }
            log("Wrote to: " + fileObject.toUri());
          } catch (IOException e) {
            fatalError("Unable to create " + resourceFile + ", " + e);
            return;
          }
        }
      }

      ...
}

Write an SPI file utility class ServicesFiles.java.

final class ServicesFiles {
  public static final String SERVICES_PATH = "META-INF/services";

  private ServicesFiles() { }

    ...

  /**
   * Writes the set of service class names to a service file.
   *
   * @param output not {@code null}. Not closed after use.
   * @param services a not {@code null Collection} of service class names.
   * @throws IOException
   */
  static void writeServiceFile(Collection<String> services, OutputStream output)
      throws IOException {
    BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(output, UTF_8));
    for (String service : services) {
      writer.write(service);
      writer.newLine();
    }
    writer.flush();
  }
}

After reviewing the source code, it can be seen that the main function of the AutoServiceProcessor is to write the classes annotated with AutoService into the SPI file, and the name of the SPI file is determined by the value of the annotation, which is the full path of the interface, and the content of the SPI file is the full path of the implementation class, with each implementation class occupying one line.

Things to note when using AutoService:

  • minSdkVersion must be at least 26

  • Implementation classes of interfaces must be public

To summarize, SPI can better achieve componentization, combining AutoService and ServiceLoader for convenience. Of course, you can also develop your own SDK and include SPIUtils for easier usage.

Applications of APT, in addition to ButterKnife and AutoService, include Alibaba’s ARouter, all of which are products of programmers’ ingenuity! I admire these experts.

Recommended Reading:

My new book, First Line of Code, 3rd Edition, has been published!

[December 10] Google DevFest 2023 Shanghai Station

I am the init process

Feel free to follow my public account

For learning technology or contributions

Understanding SPI in Android Componentization

Understanding SPI in Android Componentization

Long press the image above to recognize the QR code to follow

Leave a Comment