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

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

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

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

一、插件核心功能与优势

1. 核心功能

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

2. 解决的痛点

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

二、插件集成与使用指南

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

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

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

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中应用插件并设定配置项:

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

3. 生成多语言文件

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

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

(2)方案二 - 执行build Task

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

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

1. 插件架构设计

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

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

2. 关键功能实现

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

// 主插件中自动应用到所有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解析

@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)资源生成

 /**
* 生成或更新特定语言的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源码地址:https://github.com/linxu-link/MultilingualPlugin

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值