使用 Gradle 解决 Android 模块化项目中的多语言支持

近年来越来越多的开发者和企业把目光聚焦于海外,寻求新的增长机会。然而对于一个“土生土长”的应用,想要在海外分一杯羹面临着诸多挑战,例如当地法律法规、网络环境、用户偏好等,其中最重要的恐怕就是"语言支持"了。据 Humans 分析统计,当一个APP被翻译成某一国家的母语后,收入会增加26%,下载量会提高120%。另外,如果一个 APP 支持英语、西语和中文三种语言,几乎能够覆盖全球50%的用户,可见语言支持的重要性。

为了支持多语言,通常需要将项目中所有的字符串资源提取,交给翻译人员,然后将翻译后的字符串资源导入到项目。而对于动辄上百个模块,轻则几十个模块的模块化项目来说,字符串资源会分布在各个模块,提取字符串资源的难度和模块的数量成正比。使用脚本扫描整个工程目录可能是最简单的方案——但对使用 MultiRepo 管理代码的工程,工程往往只依赖模块的aar,仅在开发过程中才会依赖源码,如果没有相应的基础设施,需要手动 clone 所有的模块源码,这无疑也是一件麻烦事。那么有没有一种方法,不需要繁琐的操作就可以让我们拿到对应的资源呢?

对使用 Monorepo 或者非模块的工程,处理字符串资源并不是麻烦事,所以不在本文的讨论范围。

切入点分析

对于字符串资源来说,同样一个词语,在不同的上下文中具有不同的含义,例如“好的“在聊天模块作为快捷回复用语翻译为 “Okay” ,在隐私协议界面作为接受约定翻译为 “Agreed” ,所以字符串资源所在的模块对于字符的翻译也是一个重要的辅助信息,导出时要包含所属模块信息。
而对于第三方比如 AndroidX 等库的字符串资源,有的是已翻译的,有的则没有必要翻译(内部调试页面),所以我们要在实际导出过程中排除这些字段。
结合上面两点,总结我们的需求就是:能够判断字符串资源属于哪个模块,有了这个前提我们可以很容易筛选出哪些是我们关注的模块内的资源,哪些资源我们不关心应该排除。带着这个目标,分析一下能达成此目标的切入点,从而确定最终的方案。

APK 中的 resources.arsc

resources.arsc 是 Android 资源构建的产物,得益于它和资源文件、以及 R字节码文件的同时存在于 APK 中,应用开发者才能够方便的以 R.xxx.xxx 直接获取到对应的资源。resources.arsc 在这一过程中担任了资源映射的角色,负责在实际运行中映射 R.java 的引用资源 id 到实际的资源,而字符串资源也由resources.arsc 负责映射,R.string.xxx 对应的字符串可以通过查询 resources.arsc 得到。

要判断 resources.arsc 能否满足我们的需求,就需要对 resources.arsc 的定义和结构有一定的认识,resource.arsc 由 AAPT2 构建资源文件后生成,阅读AAPT2 源码是一种正统的途径,幸运的是由于Android开发工具链的完善,我们使用 Android Studio 的APK 分析器,可以更容易的查看 resources.arsc 的结构。

以 Android Studio 新建一个默认项目为例,打包后通过 Android Studio 的APK 分析器查看 resources.arsc 文件,从图中可以看到,resources.arsc 中的字符串资源没有所属模块的相关信息,并不能满足我们的需求,所以此方案不通。

AAR 文件中的 R.txt 文件

R.txt(Symbol List) 存在于 AAR 中,是 Android Library 资源构建的产物,负责在 Android 应用模块打包的过程中,传递给 Android 应用模块辅助生成模块所属的 R.class 字节码文件(为了防止资源 id 冲突,所以在 Android 应用模块打包过程中统一分配 id )。

创建一个 Android Library 并增加几个字符串资源( lib_name,test,test2 )然后打包成 AAR ,观察生成的R.txt 如图所示,R.txt 中列出了在 Android Library 中定义的所有字符串,并没有其他冗余信息。

由于我们是从 AAR 文件中获取资源,所以判断模块归属十分容易(从AAR文件名获取),R.txt + resources.arsc 的组合是否能够满足以上需求呢?

不幸的是,由于 APG 和 Android Studio 不断优化的性能和体验,导致我们产生了“错觉”—— R.txt 内容其实和 android.nonTransitiveRClass 变量的赋值有关,此属性在 Android Studio Bumblebee 后默认开启, 而我们的实验代码正是在 Android Studio Bumblebee 之后的版本上创建的,也就默认开启了"非传递性 R 类"。如果我们尝试使用 android.nonTransitiveRClass 的默认值( false )重新构建 AAR ,发现导出的 R.txt “膨胀”了 (将三方库的字符串资源也包含在内),也就没办法满足需求。

Android Studio Bumblebee 是在2022年1月发布的,而我们的项目创建远远早于这个时间,如果想开启“非传递性 R 类”,就需要利用 Android Studio Arctic Fox 及以上版本使用重构工具来启用,这对于我们来说代价同样过大,同样放弃该方案。

从长远来看,遵循 Google 最佳实践可以帮助我们更好地优化应用程序,以快速、高效地满足用户需求。因为需求排期等原因,我们选择排期迁移到“非传递 R 类”。

AAR 文件中的 res/values[-*] 目录

查阅 AAR 文档发现,每一个 AAR 文件除了必须包含 /AndroidManifest.xml 还可能包含 /res/ 目录,继续使用上文中使用的 Android Library 并增加一些字段加以测试,打包成AAR后查看 /res/values 目录发现,在AAR中,将原本 values 目录下的所有值合成到了一个文件 values.xml 中,而对于其他语言区域的支持 /res/values-en-rUS 目录也相应的生成了 values-en-rUS.xml 文件。

AAR 的 res/values[-* ] 目录中包含了仅在此模块定义的字符串资源,符合我们的需求。所以我们确定方案为从模块的 AAR 的 res/values[-* ] 目录中提取并解析 value[-* ].xml 以获得的模块的所有字符串资源

动手去做

获取AAR文件

从前面的分析中,我们发现 AAR 中的 res 目录是最佳的切入点,但是在实际的研发过程中,得益于 Gradle 的依赖管理,我们仅需要一条简单的 implementation 就可以将 AAR/JAR 依赖进工程,并不需要我们手动下载并依赖AAR文件,也就导致我们没有机会拿到 AAR 。要想知道 implementation 是怎么做到的,就需要先了解什么是implementation,我们每天都在使用它,却从未揭开他的神秘面纱。

Gradle 中的 Configuration

implementation 其实是 Java Plugin 定义的一个 Configuration ,Gradle 中为了满足不同的构建需求,使用Configuration 的概念来帮助表示依赖项作用的范围。如图中所示, implementation 表示用于源代码编译的依赖项,而 testRuntime 负责表示执行测试所需的依赖项。归根结底,他们是作用于不同构建的依赖项集合。

需要说明的是: AGP 中的 implementationapi 等配置复用了 Java Plugin 中的配置定义。

了解了 implementation 其实是一个 Configuration ,查阅文档可以使用 configurations.getByName() 查找到对应的 Configuration 实例,并尝试使用Configuration.resolve()查找并下载构成此配置的文件,返回结果文件集(这里的文件集就是 AAR/JAR 等依赖项文件)。于是在 build.gradle(module) 写下了下面的代码并运行,随即 IDE 抛出了异常 'implementation' is not allowed as it is defined as 'canBeResolved=false'.

afterEvaluate {
    val configuration = configurations.getByName("implementation")
    val resolve = configuration.resolve()
    resolve.forEach {
        println(it.absolutePath)
    }
}

# Console Error #
A problem occurred configuring project ':app'.
> Resolving dependency configuration 'implementation' is not allowed as it is defined as 'canBeResolved=false'.
  Instead, a resolvable ('canBeResolved=true') dependency configuration that extends 'implementation' should be resolved.

出现错误的原因是因为 implementation 将自己的 canBeResolved 属性设置为 false ,这就禁止了对这个 Configuration 进行任何解析,如果尝试解析就会抛出异常。如果我们拿不到依赖项,那么 AGP 插件是怎么获取到依赖项并成功打包的呢?答案就是通过"配置继承( Configuration inheritance )"——在 Gradle 中为了复用 Configuration ,可以使用"配置继承"扩展其他配置以形成继承层次结构。子 Configuration 继承在父Configuration 中声明的整套依赖项。所以我们可以尝试解析 implementation 的子 Configuration 来达到获取 implementation 的依赖项的目的。

通过翻阅源码发现,AGP 创建了一个 ${variantName}CompileClasspath 的 Configuration 去继承 compileOnlyimplementation ,由于 Configuration 的 canBeResolved 默认值为 true${variantName}CompileClasspath 并没有将自己的 canBeResolved 置为 false ,所以我们可以通过解析这个 Configuration 来获取依赖项。variantName 是构建变种名,默认会创建 release 和 debug 变种,这里我们选用 releaseCompileClasspath ,通过代码分析配置之间的依赖关系如下图

//已忽略无关代码
//https://cs.android.com/android-studio/platform/tools/base/+/mirror-goog-studio-main:build-system/gradle-core/src/main/java/com/android/build/gradle/internal/dependency/VariantDependenciesBuilder.java
package com.android.build.gradle.internal.dependency;
...
public class VariantDependenciesBuilder {

    ...
    private final Set<Configuration> compileClasspaths = Sets.newLinkedHashSet();

    public VariantDependenciesBuilder addSourceSet(@Nullable DefaultAndroidSourceSet sourceSet) {
        ....
        //获取compileOnly 
        compileClasspaths.add(configs.getByName(sourceSet.getCompileOnlyConfigurationName())); 
        //获取implementation
        final Configuration implementationConfig =
                    configs.getByName(sourceSet.getImplementationConfigurationName());
        
        compileClasspaths.add(implementationConfig);
        ....
    }
   
    public VariantDependencies build() {
        ...
        final ConfigurationContainer configurations = project.getConfigurations();
        final DependencyHandler dependencies = project.getDependencies();

        final String compileClasspathName = variantName + "CompileClasspath";
        Configuration compileClasspath = configurations.maybeCreate(compileClasspathName);
        compileClasspath.setVisible(false);
        compileClasspath.setDescription(
                "Resolved configuration for compilation for variant: " + variantName);
        compileClasspath.setExtendsFrom(compileClasspaths); //设置配置继承关系
        ...
        compileClasspath.setCanBeConsumed(false);
        compileClasspath
                .getResolutionStrategy()
                .sortArtifacts(ResolutionStrategy.SortOrder.CONSUMER_FIRST);
    }
}

从依赖关系图可以看出,对于 releaseImplementationreleaseApi 等依赖方式,使用 releaseCompileClasspath 同样也能获取到对应的依赖项,通常 implementation 等不可解析对象仅作为定义存在。可解析配置将扩展至少一个不可解析的配置(并且可以扩展多个配置)。

成功的尝试

基于分析,我们重新修改获取 AAR 逻辑,可以看到在控制台成功输出了所有依赖项的路径。接下来,我们筛选出我们需要翻译的模块 AAR ,解压到临时文件,并解析 res/values[-*] 目录下的 xml 文件,那么该模块下所有的已有的字符串资源就已经被成功获取了,导出的格式选择就取决于产品或者翻译平台的格式要求。

dependencies {  
    implementation("androidx.core:core-ktx:1.9.0")  
    releaseImplementation("com.squareup.okhttp3:okhttp:4.11.0")  
    testImplementation(libs.junit)  
    androidTestImplementation(libs.androidx.test.ext.junit)  
    androidTestImplementation(libs.espresso.core)  
}  
  
afterEvaluate {  
    val configuration = configurations.getByName("releaseRuntimeClasspath")  
    val resolve = configuration.resolve()  
    resolve.forEach {  
        println(it.absolutePath)  
    }  
}

# Console Output #
> Configure project :app
/home/prosixe/.gradle/caches/modules-2/files-2.1/com.squareup.okhttp3/okhttp/4.11.0/436932d695b2c43f2c86b8111c596179cd133d56/okhttp-4.11.0.jar
/home/prosixe/.gradle/caches/modules-2/files-2.1/com.squareup.okio/okio-jvm/3.2.0/332d1c5dc82b0241cb1d35bb0901d28470cc89ca/okio-jvm-3.2.0.jar
/home/prosixe/.gradle/caches/modules-2/files-2.1/org.jetbrains.kotlin/kotlin-stdlib-jdk8/1.8.0/ed04f49e186a116753ad70d34f0ac2925d1d8020/kotlin-stdlib-jdk8-1.8.0.jar
/home/prosixe/.gradle/caches/modules-2/files-2.1/androidx.core/core/1.9.0/aa21c91d72e5d2a8dcc00c029ec65fc8d804ce02/core-1.9.0.aar
/home/prosixe/.gradle/caches/modules-2/files-2.1/androidx.core/core-ktx/1.9.0/b56f6b1bcb7882a9933c963907818da2094ae3a4/core-ktx-1.9.0.aar
/home/prosixe/.gradle/caches/modules-2/files-2.1/org.jetbrains.kotlin/kotlin-stdlib-jdk7/1.8.0/3c91271347f678c239607abb676d4032a7898427/kotlin-stdlib-jdk7-1.8.0.jar
/home/prosixe/.gradle/caches/modules-2/files-2.1/androidx.annotation/annotation-experimental/1.3.0/5087c6f545117dcd474e69e1a93cacec9d7334af/annotation-experimental-1.3.0.aar
....

更进一步

大家都知道,Android 打包过程中资源和源码分别有不同的编译流程,那么在打包的过程中肯定不是让 AAR 整个文件直接参与打包流程的,如果是资源和源码分别参与到流程中,那么它们的中间文件放置在哪里呢?如果我们找到了中间文件是不是就可以免去解压这个步骤,直接解析提取了呢?带着问题,我们从源码中寻找答案。

AGP 中的 TransformAction

TransformAction 是 Gradle 中负责转换工件( Artifact ,在Gradle中通常表示一个构建产物,例如 AAR,JAR,WAR 等等)的 API ,通常负责将一组属性( Attributes )转到另一组属性。当 Gradle 尝试去解析一个 Configuration 的时候,如果某些依赖项并没有请求属性的对应变体的时候,Gradle 会从已注册 registerTransform 的 TransformAction 中尝试找到一条组合路径,能够将现有的依赖项转换为带有请求属性的依赖项目。举个例子: 在 Android 编译过程中,android.enableJetifier=true 时,假设请求的属性为 PROCESSED_AAR ,而我们依赖项的现有属性为 AAR ,那么 Gradle 会找到一条 TransformAction 组合路径,能从 AAR 转换为 PROCESSED_AAR 。

需要注意的是 artifactType 属性很特殊,因为它仅存在于已解析的工件上,而不存在于依赖项上。因此,在解析仅有 artifactType 请求属性的配置时,任何只修改 artifactType 属性的 TransformAction 将不会被选中。只有在使用ArtifactView时才会考虑选中。

通过在AGP中查找 TransformAction 的定义和注册,发现AGP中有两个 TransformAction 子类和 AAR 解压和解析有关。他们分别是 ExtractAarTransform AarTransform ,通过分析代码发现 ExtractAarTransform 负责将输入AAR文件解压,而 AarTransform 负责返回AAR解压文件夹中某个资源的路径。分析他们的 registerTransform 代码发现,如果我们想从 AndroidArtifacts.ArtifactType.AAR 属性转到 AndroidArtifacts.ArtifactType.ANDROID_RES属性,由 ExtractAarTransform 和 AarTransform 组合正好能构成一条转换路径( AAR -> EXPLODED_AAR -> ANDROID_RES )

//代码地址:https://cs.android.com/android-studio/platform/tools/base/+/mirror-goog-studio-main:build-system/gradle-core/src/main/java/com/android/build/gradle/internal/DependencyConfigurator.kt
//ExtractAarTransform的注册
        registerTransform(
            ExtractAarTransform::class.java,
            aarOrJarTypeToConsume.aar,//AndroidArtifacts.ArtifactType.AAR
            AndroidArtifacts.ArtifactType.EXPLODED_AAR
        )
//AarTransform的注册 getTransformTargets是不同的转换目标属性,循环注册
        for (transformTarget in AarTransform.getTransformTargets(aarOrJarTypeToConsume)) {
            dependencies.registerTransform(
                AarTransform::class.java
            ) { spec ->
                spec.from.attribute(ArtifactTypeDefinition.ARTIFACT_TYPE_ATTRIBUTE, AndroidArtifacts.ArtifactType.EXPLODED_AAR.type)
                spec.from.attribute(Category.CATEGORY_ATTRIBUTE, libraryCategory)
                spec.to.attribute(ArtifactTypeDefinition.ARTIFACT_TYPE_ATTRIBUTE, transformTarget.type)
                spec.to.attribute(Category.CATEGORY_ATTRIBUTE, libraryCategory)
                spec.parameters.projectName.setDisallowChanges(project.name)
                spec.parameters.targetType.setDisallowChanges(transformTarget)
                spec.parameters.sharedLibSupport.setDisallowChanges(sharedLibSupport)
            }
        }

----From AarTransform
    @NonNull
    public static ArtifactType[] getTransformTargets(AarOrJarTypeToConsume aarOrJarTypeToConsume) {
        return new ArtifactType[] {
            aarOrJarTypeToConsume.getJar(),
            // For CLASSES, this transform is ues for runtime, and AarCompileClassesTransform is
            // used for compile
            ArtifactType.SHARED_CLASSES,
            ArtifactType.JAVA_RES,
            ArtifactType.SHARED_JAVA_RES,
            ArtifactType.MANIFEST,
            ArtifactType.ANDROID_RES,
            ArtifactType.ASSETS,
            ArtifactType.SHARED_ASSETS,
            ArtifactType.JNI,
            ArtifactType.SHARED_JNI,
            ArtifactType.AIDL,
            ArtifactType.RENDERSCRIPT,
            ArtifactType.UNFILTERED_PROGUARD_RULES,
            ArtifactType.LINT,
            ArtifactType.ANNOTATIONS,
            ArtifactType.PUBLIC_RES,
            ArtifactType.COMPILE_SYMBOL_LIST,
            ArtifactType.DATA_BINDING_ARTIFACT,
            ArtifactType.DATA_BINDING_BASE_CLASS_LOG_ARTIFACT,
            ArtifactType.RES_STATIC_LIBRARY,
            ArtifactType.RES_SHARED_STATIC_LIBRARY,
            ArtifactType.PREFAB_PACKAGE,
            ArtifactType.AAR_METADATA,
            ArtifactType.ART_PROFILE,
            ArtifactType.NAVIGATION_JSON,
        };
    }

上文提到,要想触发artifactType的属性转换,需要我们使用ArtifactViewAndroidArtifacts.ArtifactType.AARAndroidArtifacts.ArtifactType.ANDROID_RES 属性就是 artifactType 的属性,这一点可以从 AndroidArtifacts 源码得知,所以修改获取方式如下,并成功获取了位于 Gradle 缓存路径下的已解压的 AAR 路径,现在我们不用解压缩,就可以拿到一个模块下所有的字符串资源了。

afterEvaluate {
    val configuration = configurations.getByName("releaseRuntimeClasspath")
    val artifacts = configuration.incoming.artifactView {
        attributes.attribute(
            AndroidArtifacts.ARTIFACT_TYPE,
            AndroidArtifacts.ArtifactType.ANDROID_RES.type
        )
    }.artifacts
    artifacts.artifactFiles.forEach {
        println(it)
    }
}

#output 
/home/prosixe/.gradle/caches/transforms-3/b15e5c11ab5458dc40071e292632fa4c/transformed/core-1.9.0/res
/home/prosixe/.gradle/caches/transforms-3/5409291e0f20302a556f1f822f907835/transformed/core-ktx-1.9.0/res
/home/prosixe/.gradle/caches/transforms-3/7589258eaf55d137a37e590e9111e0d3/transformed/annotation-experimental-1.3.0/res
/home/prosixe/.gradle/caches/transforms-3/67e97da11edfa018e3236656720b711b/transformed/lifecycle-runtime-2.3.1/res

Gradle获取res目录

通常翻译后的字符需要导入到源码路径,使用 Gradle 插件可以很好的完成这一任务,甚至定制插件可以让你在打包时拥有更多的灵活性,如果你想在 Gradle 中获取项目的资源路径,如果你的项目有多个变种,写死路径的方式可能并不是那么优美,你可以使用 Gradle 的 API 去获取,针对不同的变种修改 findByName 的值就可以轻松实现。

val extension = project.extensions.getByName("android") as BaseExtension  
val sourceSets = extension.sourceSets  
val mainSourceSets = sourceSets.findByName("main")  
val dirs = mainSourceSets!!.res.srcDirs

最后

本文通过一个实际的需求引导读者一步步深入思考和解决问题。虽然最终的解决方案仅需几行代码,但这个过程对于培养解决问题的能力非常有价值。当面对问题时,如果在搜索引擎中无法找到现成的解决方案,不妨静下心来,深入研究源代码,去挖掘其中潜藏的宝藏。

Android生态是一个开放的世界,除了Android系统源码之外,你还可以在https://cs.android.com/ 找到Android Studio、AndroidX等相关工具和库的源代码。善于利用这些资源,将有助于你更好地理解Android开发,并提升自己的技术水平。在实践中不断挑战和突破自己,是成为一名优秀Android开发者的关键。

#Gradle #Android