[运用出海] 运用 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 中的字符串资源没有所属模块的相关信息,并不能满意咱们的需求,所以此计划不通。

[应用出海] 使用 Gradle 解决 Android 模块化项目中的多语言支持

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 中界说的一切字符串,并没有其他冗余信息。

[应用出海] 使用 Gradle 解决 Android 模块化项目中的多语言支持

因为咱们是从 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 “胀大”了 (将三方库的字符串资源也包括在内),也就没办法满意需求。

[应用出海] 使用 Gradle 解决 Android 模块化项目中的多语言支持

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 以取得的模块的一切字符串资源

[应用出海] 使用 Gradle 解决 Android 模块化项目中的多语言支持

动手去做

获取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 中的装备界说。

[应用出海] 使用 Gradle 解决 Android 模块化项目中的多语言支持

了解了 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);
    }
}

[应用出海] 使用 Gradle 解决 Android 模块化项目中的多语言支持

从依靠联系图能够看出,关于 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体系源码之外,你还能够在cs.android.com/ 找到Android Studio、AndroidX等相关东西和库的源代码。善于利用这些资源,将有助于你更好地理解Android开发,并提升自己的技术水平。在实践中不断应战和突破自己,是成为一名优秀Android开发者的关键。