【车载Android】使用自定义插件实现多语言自动化适配

2024年中国成为世界第一汽车出口大国,车载Android应用的全球化适配需求也日益迫切。在实际开发中,多语言适配往往是一项繁琐且容易出错的工作,博主曾对Jira上百个翻译错误的Bug单不停地叹气,无论是翻译人员、测试工程师还是开发人员,都需要在无聊的重复劳动中耗费大量时间和精力。

为解决这一痛点,博主基于实践经验开发了MultilingualPlugin多语言自动化插件,目前已开源,希望能帮助更多车载Android开发者提升开发时的效率。

MultilingualPlugin源码地址:github.com/linxu-link/...

一、插件核心功能与优势

1. 核心功能

  • Excel驱动翻译:通过Excel文件统一管理多语言文本,只需维护一份表格即可生成所有语言的资源文件。
  • 自动匹配与生成 :插件会自动读取基准语言(如中文)的strings.xml,并根据Excel中的翻译内容生成其他语言的values-xx目录及对应文件。
  • 全项目适配 :支持多模块工程(如车载应用常见的主应用+子模块结构),只需在根目录配置一次,即可自动应用到所有applib模块,也支持仅配置单一模块的场景。
  • 增量更新:新增或修改翻译时,插件会智能更新已有文件,避免重复生成导致的冲突。

2. 解决的痛点

  • 减少人工错误:避免手动复制粘贴翻译内容导致的错别字、标签遗漏等问题。
  • 提升协作效率:翻译人员只需关注Excel表格,开发人员无需手动维护多语言文件,测试人员也可快速验证翻译一致性。
  • 适配车载应用:针对车载系统可能需要支持的多种语言(如英语、日语、韩语、欧洲各语言等),实现一键生成,适配全球化车型需求。

二、插件集成与使用指南

1. 集成方式(Kotlin DSL示例)

(1)使用方式一 - 全局应用

根目录build.gradle.kts中应用插件并设定配置项:

dart 复制代码
plugins {
alias(libs.plugins.android.application) apply false
    alias(libs.plugins.kotlin.android) apply false
    alias(libs.plugins.android.library) apply false
    id("io.github.linxu-link.multilingual") version "0.2.0"
}

multilingual {
// 启用多语言适配,默认关闭
    enable.set(true)
    // 使用project.rootDir获取项目根目录,再拼接相对路径
    excelFilePath.set(file("${project.rootDir}/language/多语言V1.0.xlsx").absolutePath)
    // 基准语言目录,必须与代码中资源文件目录一致
    baselineDir.set("values")
    // 基准语言编码,必须与Excel文件中的语言编码一致
    defaultLanguage.set("zh-rCN")
} 

(2)使用方式二 - 单模块应用

模块build.gradle.kts中应用插件并设定配置项:

csharp 复制代码
plugins {
alias(libs.plugins.android.application)
    alias(libs.plugins.kotlin.android)
    id("io.github.linxu-link.multilingual") version "0.2.0"
}

multilingual {
// 启用多语言适配,默认关闭
    enable.set(true)
    // 使用project.rootDir获取项目根目录,再拼接相对路径
    excelFilePath.set(file("${project.rootDir}/language/多语言V1.0.xlsx").absolutePath)
    // 基准语言目录,必须与代码中资源文件目录一致
    baselineDir.set("values")
    // 基准语言编码,必须与Excel文件中的语言编码一致
    defaultLanguage.set("zh-rCN")
} 

全局应用和单模块应用,两种应用方式是互斥的,根据你的需要只在一个build.gradle中配置即可。

MultilingualPlugin有四个配置项

  • enable:是否启用插件,默认为false。在生成多语言字符串资源后,应该将插件关闭,防止拖慢正常的编译流程。
  • excelFilePath:Excel翻译文件的路径。
  • baselineDir :基准语言的目录,默认为valuesMultilingualPlugin 会以基准语言目录下的string.xml为蓝本,获取生成其他语言需要的string name,所以**baselineDir下的string.xml**必须是完整的。
  • defaultLanguage :基准语言在Excel内的编码,默认为zh-rCN

2. Excel文件格式规范

表头 :定义语言类型,格式为**语言名称/语言编码**(如Chinese/zh-rCNEnglish/en)。

语言名称可以自行定义,插件不会进行解析,/后的语言编码必须是符合Android多语言规范的编码,插件会根据语言编码生成对应的values文件夹。示例如下:

Chinese/zh-rCN English/en-rUS Japanese/ja-rJP Korean/ko-rKR
我的应用 My Application マイアプリ 내 앱
你好,世界! Hello World! こんにちは、世界! 안녕, 세계!
欢迎使用本应用。 Welcome to the app. アプリへようこそ。 앱에 오신 것을 환영합니다.
设置 Settings 設定 설정
登录 Login ログイン 로그인
退出登录 Logout ログアウト 로그아웃
用户名 Username ユーザー名 사용자 이름
密码 Password パスワード 비밀번호

3. 生成多语言文件

(1)方案一 - 执行Gradle任务

bash 复制代码
./gradlew generateTranslations  # 生成所有模块的多语言文件
./gradlew :app:generateTranslations  # 生成指定模块的文件

(2)方案二 - 执行build Task

插件会自动在res目录下生成values-envalues-ja等目录,并创建对应的strings.xml,内容基于Excel翻译生成。

三、插件源码核心逻辑解读

1. 插件架构设计

插件采用Gradle插件标准架构,主要包含三个核心部分:

  • 主插件类( MultilingualPlugin):负责初始化配置、监听项目生命周期,并为符合条件的模块(Android应用/库)注册子插件。
  • 模块插件类( MultilingualModulePlugin):为单个模块添加翻译生成任务,并关联到构建流程。
  • 任务类( MultilingualTask):核心逻辑实现,负责解析Excel、读取基准语言文件、生成翻译资源。

2. 关键功能实现

(1)自动应用与配置继承

kotlin 复制代码
// 主插件中自动应用到所有Android模块  
override fun apply(project: Project) {
    if (project == project.rootProject) {
        // 根项目创建全局配置扩展  
        project.extensions.create("multilingual", MultilingualExtension::class.java)
        // 监听子项目,自动应用模块插件  
        project.rootProject.subprojects { subproject ->
            subproject.afterEvaluate {
                if (it.plugins.hasPlugin("com.android.application") || 
                    it.plugins.hasPlugin("com.android.library")) {
                    it.plugins.apply(MultilingualModulePlugin::class.java)
                }
            }
        }
    }
}

通过subprojects监听所有子模块,自动为Android模块应用插件,避免手动配置每个模块。

(2)Excel解析

kotlin 复制代码
@TaskAction
fun generateTranslations() {
    val excelFile = File(excelFilePath.get())
    if (!excelFile.exists()) {
        throw GradleException("==> Excel文件不存在: ${excelFile.absolutePath}")
    }
    // 查找Android项目的res目录
    val resDir = findAndroidResDirectory()
    // 读取默认语言的string.xml文件
    val baselineValuesDir = File(resDir, baselineDir.get())
    if (!baselineValuesDir.exists()) {
        throw GradleException("==> 基准语言目录不存在: ${baselineValuesDir.absolutePath}")
    }

    val defaultStringsFile = File(baselineValuesDir, "strings.xml")
    if (!defaultStringsFile.exists()) {
        throw GradleException("==> 默认语言的strings.xml不存在: ${defaultStringsFile.absolutePath}")
    }

    // 解析默认strings.xml获取键值对
    val defaultStrings = parseStringsXml(defaultStringsFile)
    logger.lifecycle("==> 从${defaultStringsFile.name}读取到${defaultStrings.size}个字符串")

    // 读取并解析Excel文件
    WorkbookFactory.create(excelFile.inputStream()).use { workbook ->
val sheet = workbook.getSheetAt(0) ?: throw GradleException("Excel中没有工作表")

        // 解析第一行获取语言编码信息
        val headerRow = sheet.getRow(0) ?: throw GradleException("Excel中没有标题行")
        val languageCodes = mutableMapOf<Int, String>() // 列索引 -> 语言编码

        for (col in 0 until headerRow.lastCellNum) {
            val cell = headerRow.getCell(col)?.stringCellValue ?: continue
            val code = cell.split("/").lastOrNull()?.trim()
            if (code != null && code.isNotEmpty()) {
                languageCodes[col] = code
                logger.lifecycle("==> 检测到语言: $code (列索引: $col)")
            }
        }

        // 找到默认语言在Excel中的列索引
        val defaultLangCol = languageCodes.entries
            .find { it.value == defaultLanguage.get() } ?.key
            ?: throw GradleException("==> Excel中未找到默认语言: ${defaultLanguage.get()}")

        // 处理excel每一行数据
        for (rowNum in 1..sheet.lastRowNum) {
            val row = sheet.getRow(rowNum) ?: continue
            val defaultLangCell = row.getCell(defaultLangCol) ?: continue
            val defaultText = defaultLangCell.stringCellValue.trim()

            if (defaultText.isEmpty()) {
                continue
            }
            // 找到对应的key
            val key = defaultStrings.entries.find { it.value == defaultText } ?.key
            if (key == null) {
                logger.warn("==> 在默认strings.xml中未找到文本对应的key: $defaultText (行号: ${rowNum + 1})")
                continue
            }

            // 为每种语言生成翻译
            languageCodes.forEach { (colIndex, langCode) ->
val translationCell = row.getCell(colIndex) ?: return@forEach
                val translationText = translationCell.stringCellValue.trim()

                // 跳过默认语言,因为它已经存在
                if (langCode == defaultLanguage.get()) return@forEach

                // 生成对应语言的strings.xml
                generateLanguageFile(resDir, langCode, key, translationText)
            }
}
    }
}

核心逻辑:

  • 解析Excel表头获取语言编码(如zh-rCN),生成对应values-xx目录。

  • 通过DOM操作读取基准语言strings.xml,匹配Excel中的翻译内容,生成新的翻译节点。

  • 自动处理XML特殊字符转义(如&&amp;),并清理无效空白节点,保证文件格式规范。

(3)资源生成

scss 复制代码
 /**
* 生成或更新特定语言的strings.xml文件
*/
private fun generateLanguageFile(resDir: File, langCode: String, key: String, value: String) {
    val langDir = if (langCode.isEmpty()) {
        File(resDir, "values")
    } else {
        File(resDir, "values-$langCode")
    }

    // 确保目录存在
    if (!langDir.exists()) {
        langDir.mkdirs()
    }

    val stringsFile = File(langDir, "strings.xml")
    val doc = if (stringsFile.exists()) {
        // 如果文件存在,读取现有内容
        DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(stringsFile)
    } else {
        // 如果文件不存在,创建新的XML文档
        val docBuilder = DocumentBuilderFactory.newInstance().newDocumentBuilder()
        val doc = docBuilder.newDocument()
        val resources = doc.createElement("resources")
        doc.appendChild(resources)
        doc
    }

    doc.documentElement.normalize()
    val resources = doc.documentElement

// 检查是否已有该key的翻译
    var stringNode: Element? = null
    val existingNodes: NodeList = resources.getElementsByTagName("string")
    for (i in 0 until existingNodes.length) {
        val node = existingNodes.item(i) as Element
        if (node.getAttribute("name") == key) {
            stringNode = node
            break
        }
    }

    // 如果存在则更新,不存在则创建
    if (stringNode != null) {
        stringNode.textContent = escapeXml(value)
    } else {
        stringNode = doc.createElement("string")
        stringNode.setAttribute("name", key)
        stringNode.textContent = escapeXml(value)
        resources.appendChild(stringNode)
    }
    // 清理可能的空文本节点
cleanEmptyTextNodes(resources)

    // 保存文件 - 优化XML格式化配置
    val transformerFactory = TransformerFactory.newInstance()
    val transformer = transformerFactory.newTransformer()

    // 关键优化:设置缩进和编码,避免多余空行
    transformer.setOutputProperty(OutputKeys.INDENT, "yes")
    transformer.setOutputProperty(OutputKeys.ENCODING, "UTF-8")
    transformer.setOutputProperty(OutputKeys.METHOD, "xml")
    transformer.setOutputProperty(OutputKeys.OMIT_XML_DECLARATION, "no")
    transformer.setOutputProperty("{http://xml.apache.org/xslt}indent-amount", "4")
    // 写入文件
    val result = StreamResult(stringsFile)
    transformer.transform(DOMSource(doc), result)

    logger.lifecycle("==> 已更新、翻译: $langCode/$key = $value")
}

插件通过project.rootDir获取根目录Excel文件,确保多模块共享同一份翻译数据;在生成翻译时,会检查已有strings.xml中的节点,存在则更新,不存在则新增,实现增量更新。

总结

由于 MultilingualPlugin 在使用时,会修改已经存在的strings.xml,所以在使用插件之前务必!务必!将工程代码进行备份,防止出现代码丢失等意外情况!

实践下来MultilingualPlugin可以解决90%以上的翻译问题,但是由于不同的工程结构存在差异,而且一些公司车载应用的strings.xml还会进一步定制化,所以如果需要对自动化插件进行定制,请下载MultilingualPlugin源代码,进行修改。

如果之前没有开发Gradle插件的经验,可以继续阅读后续的文章,了解如何开发一个插件以及如何将插件上传到 plugins.gradle.org上。

MultilingualPlugin源码地址:github.com/linxu-link/...

相关推荐
消失的旧时光-19438 小时前
Flutter 响应式 + Clean Architecture / MVU 模式 实战指南
android·flutter·架构
404未精通的狗8 小时前
(数据结构)栈和队列
android·数据结构
恋猫de小郭9 小时前
今年各大厂都在跟进的智能眼镜是什么?为什么它突然就成为热点之一?它是否是机会?
android·前端·人工智能
游戏开发爱好者811 小时前
iOS 混淆工具链实战 多工具组合完成 IPA 混淆与加固 无源码混淆
android·ios·小程序·https·uni-app·iphone·webview
豆豆豆大王16 小时前
Android 数据持久化(SharedPreferences)
android
Paper_Love16 小时前
RK3588-android-reboot命令内核调用流程
android
介一安全16 小时前
【Frida Android】基础篇12:Native层hook基础——调用原生函数
android·网络安全·逆向·安全性测试·frida·1024程序员节
2501_9160088917 小时前
用多工具组合把 iOS 混淆做成可复用的工程能力(iOS混淆|IPA加固|无源码混淆|Ipa Guard|Swift Shield)
android·开发语言·ios·小程序·uni-app·iphone·swift
Zach_yuan17 小时前
程序地址空间
android·linux·运维·服务器