Android国际化翻译多国String导入导出(EXCEL)实现

Posted QIANDXX

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Android国际化翻译多国String导入导出(EXCEL)实现相关的知识,希望对你有一定的参考价值。

前言

突然发现自己已经几个月没有更博了,一方面是因为最近换了工作比较忙,另一方面也是因为感觉没什么好分享的(也可能是因为懒。

恰好最近做公司项目涉及到大量文本的国际化翻译问题,手动一个个添加肯定不现实,浪费时间又容易出错。本来部门内部已经有了相关的脚本,可以把项目中的string导出为excel文件,然后发给专业翻译进行翻译,之后再导入到项目中;但是我用了这个脚本后发现问题太多,而且已经无人维护了,不得不痛下杀手完全重写了一个新的,本文就分享出来,简单记录一下。

使用方法

  1. clone项目到本地,修改module_string_script模块中Config类的配置

  2. 运行ExportStrings2ExcelScript.kt导出目标项目中的string内容,导出后如下图

  3. 把导出的excel第一列锁定起来防止修改,然后提供出去翻译

  4. 运行ImportFromExcelScript.kt把翻译完的excel内容导入到项目里面,如果isBaseOnWord为true,导入时需要至少需要一个参照列(BASE_LANG或者DEFAULT_LANG

  5. 建议导入前先提交代码,这样导入之后就可以清楚看到变动了,方便进行二次确认

实现过程和遇到的问题

导出

  1. 遍历项目每个模块,使用dom4j库解各国语言的string.xml,存放到一个LinkedHashMap<语言目录(如values-zh-rCN),<name,word>>

    private const val colHead = "name"
    private const val ROOT_TAG = "resources"
    private const val TAG_NAME = "string"
    
    /**
     * 解析当前的多语言内容 <语言目录(如values-zh-rCN),<name,word>>
     */
    fun collectRes(res: File): LinkedHashMap<String, LinkedHashMap<String, String>> 
        val hashMap = LinkedHashMap<String, LinkedHashMap<String, String>>()
        hashMap[colHead] = LinkedHashMap()
        val saxReader = SAXReader()
        res.listFiles().forEach  langDir ->
            val stringFile = File(langDir, "strings.xml")
            if (!stringFile.exists())
                return@forEach
            val data = LinkedHashMap<String, String>()
            // 收集所有string name,作为excel第一列
            val names = hashMap.computeIfAbsent(colHead)  LinkedHashMap() 
            val doc = saxReader.read(stringFile)
            val root = doc.rootElement
            if (root.name == ROOT_TAG) 
                val iterator = root.elementIterator()
                while (iterator.hasNext()) 
                    val element = iterator.next()
                    if (element.name == TAG_NAME) 
                        val name = element.attribute("name").text
                        val word = element.text
                        names[name] = name
                        data[name] = word
                    
                
            
            hashMap[langDir.name] = data
        
        return hashMap
     
    
  2. 转换数据结构,把<语言目录(如values-zh-rCN),<name,word>>结构转换成<name,<语言目录,word>>结构,方便后续操作

  3. 根据策略对解析到的内容做处理,可以把相同内容但是不同name的string排列到一起,或者去重Config.isBaseOnWord表示是否基于string内容去重,BASE_LANG表示基于哪个语言的内容进行去重

    /**
     *  处理可能出现的相同内容但是不同key的string
     *  [source] <name,<语言目录,word>>
     *  @return <name,<语言目录,word>>
     */
    fun processSameWords(source: LinkedHashMap<String, LinkedHashMap<String, String>>): LinkedHashMap<String, LinkedHashMap<String, String>> 
        // 由于可能存在不同key但是相同内容的string,导出时将内容相同的string聚合到一起
        val haveCNKey = source.entries.first().value.containsKey(Config.BASE_LANG)
        val baseLang = if (haveCNKey) Config.BASE_LANG else Config.DEFAULT_LANG
        // 是否根据中文或者默认语言的内容为基准去重,否则将相同的内容行排序到一起
        return if (Config.isBaseOnWord) 
            // 去重
            source.entries.distinctBy 
                val baseWord = it.value[baseLang]
                return@distinctBy if (!baseWord.isNullOrBlank())
                    baseWord
                else
                    it
            .fold(linkedMapOf())  acc, entry ->
                acc[entry.key] = entry.value
                acc
            
         else 
            // 相同的排到一起
            source.entries.sortedBy 
                val baseWord = it.value[baseLang]
                if (!baseWord.isNullOrEmpty())
                    return@sortedBy baseWord
                else
                    return@sortedBy null
            .fold(linkedMapOf())  acc, entry ->
                acc[entry.key] = entry.value
                acc
            
        
     
    
  4. 将Map数据写入Excel文件

导入

  1. poi读取excel文件内容,存放在一个Map<表名,<name,<语言目录,值>>>中```
    /**

    • 获取excel某个表的数据 <表名,<name,<语言目录,值>>>
      */
      fun getSheetsData(filePath: String): LinkedHashMap<String, LinkedHashMap<String, LinkedHashMap<String, String>>>
      val inputStream = FileInputStream(filePath)
      val excelWBook = XSSFWorkbook(inputStream)
      val map = linkedMapOf<String, LinkedHashMap<String, LinkedHashMap<String, String>>>()
      excelWBook.forEach
      val dataMap = LinkedHashMap<String, LinkedHashMap<String, String>>()
      val head = ArrayList()
      //获取工作簿
      val excelWSheet = excelWBook.getSheet(it.sheetName)
      excelWSheet.run
      // 总行数
      val rowCount = lastRowNum - firstRowNum + 1
      // 总列数
      val colCount = getRow(0).physicalNumberOfCells
      // 获取所有语言目录
      for (col in 0 until colCount)
      head.add(getCellData(excelWBook, sheetName, 0, col))

      for (row in 1 until rowCount) 
          // 第一列为string name
          val name = getCellData(excelWBook, sheetName, row, 0)
          Log.d(TAG, "第$row行,name = $name")
          val v = LinkedHashMap<String, String>()
          for (col in 0 until colCount) 
              val content = getCellData(excelWBook, sheetName, row, col)
              val text = WordHelper.escapeText(content)
              v[head[col]] = text
              Log.d(TAG, "lang = $head[col] ,value = $v[head[col]]")
              
          dataMap[name] = v
          
      
          excelWBook.close()
          inputStream.close()
      
      map[it.sheetName] = dataMap
      


      excelWBook.close()
      return map

    
    
  2. 遍历Map,将每个表中的数据结构转换成Map<语言目录(如values-zh-rCN),<name,word>>结构,方便合并```
    /**

    • 还原string map数据结构,以language为行标识方便写入多个string.xml
    • [source] <name,<语言目录,word>>
    • @return <语言目录(如values-zh-rCN),<name,word>>
      */
      fun revertResData(source: LinkedHashMap<String, LinkedHashMap<String, String>>): LinkedHashMap<String, LinkedHashMap<String, String>>
      // <语言目录(如values-zh-rCN),<name,word>>
      val resData = LinkedHashMap<String, LinkedHashMap<String, String>>()
      source.forEach (name, value) ->
      value.forEach (langDir, word) ->
      val langRes = resData.computeIfAbsent(langDir) LinkedHashMap()
      langRes[name] = word


      return resData
    
    
  3. 再次读取项目中所有模块的string存到一个Map<语言目录,<name,word>>里,和从excel表中读出的Map进行合并```
    /**

    • 将excel中string合并到项目原本string中,不存在则追加,存在则覆盖
    • [newData] excel中读出的string <语言目录,<name,word>>
    • [resData] 项目中读出的string <语言目录,<name,word>>
      /
      fun mergeLangNameString(
      newData: LinkedHashMap<String, LinkedHashMap<String, String>>,
      resData: LinkedHashMap<String, LinkedHashMap<String, String>>
      )
      if (Config.isBaseOnWord)
      /
      *
      * 1. excel中某个string name匹配到了项目中的某个string name
      * 2. 找到项目中和该string基准语言的内容相同的其他string
      * 3. 将这些string视为相同的string,复制一份添加到newData中
      */
      val baseLang = if (newData.containsKey(Config.BASE_LANG)) Config.BASE_LANG else Config.DEFAULT_LANG
      val baseLangMap = newData[baseLang]
      if (baseLangMap != null)
      // 寻找基准值相同的string
      val sameWords = baseLangMap.map (name, newWord) ->
      val oldBaseWord = resData[baseLang]?.get(name)
      return@map name to resData[baseLang]?.filter
      if (!oldBaseWord.isNullOrBlank())
      return@filter it.value == oldBaseWord

      false
      ?.keys

      sameWords.forEach pair ->
      if (pair.second?.size ?: 0 > 1)
      Log.e(TAG, “newName: p a i r . f i r s t m a p p i n g o l d n a m e s : pair.first mapping old names: pair.firstmappingoldnames:pair.second”)

      val newName = pair.first
      pair.second?.forEach oldName ->
      newData.forEach (lang, map) ->
      map[oldName] = map[newName] ?: “”





      resData.keys.reversed().forEach
      if (!newData.keys.contains(it))
      resData.remove(it)
      // excel中不存的语言列直接移除掉,不参与合并
      Log.e(TAG, “new data have no lang dir:$it, skip”)


      // 遍历更新项目string
      newData.forEach (lang, map) ->
      // 排除第一列
      if (lang == colHead)
      return@forEach
      var hasChanged = false
      // 当前项目中一条包含多语种的string map
      val nameWordMap = resData.computeIfAbsent(lang) linkedMapOf()
      map.forEach (name, newWord) ->
      // 项目中存在该语言的字符,遍历每个的值,依次覆盖为excel中的新值
      if (name.isNotEmpty() && newWord.isNotBlank())
      val oldWord = nameWordMap[name]
      if (oldWord != null && oldWord.isNotEmpty())
      if (oldWord != newWord)
      hasChanged = true
      Log.e(
      TAG,
      “替换string:[name: $name, lang: l a n g , 旧 值 : lang, 旧值: lang,oldWord, 新值:$newWord]”
      )

      else
      hasChanged = true
      Log.e(TAG, “新增string:[name: $name, lang: $lang, 新值: $newWord]”)

      nameWordMap[name] = newWord


      if (!hasChanged)
      // 该语言string内容没有变动,也跳过
      resData.remove(lang)
      Log.e(TAG, “lang dir $lang have no change, skip”)


    
    
  4. 遍历合并后的string map,使用dom4j修改或者创建原本项目中对应的string.xml文件```
    /**

    • 将excel中string导入到项目
      */
      fun importWords(newLangNameMap: LinkedHashMap<String, LinkedHashMap<String, String>>, parentDir: File)
      newLangNameMap.forEach (langDir, hashMap) ->
      Log.e(TAG, “import lang dir KaTeX parse error: Expected '', got 'EOF' at end of input: …le(parentDir, "langDir/strings.xml”)
      if (stringFile.exists())
      // 修改原本dom
      val saxReader = SAXReader()
      val doc = saxReader.read(stringFile)
      val root = doc.rootElement
      val nodeMap = linkedMapOf<String, Element>()
      if (root.name == ROOT_TAG)
      val iterator = root.elementIterator()
      while (iterator.hasNext())
      val element = iterator.next()
      if (element.name == TAG_NAME)
      val name = element.attribute(“name”).text
      nodeMap[name] = element



      hashMap.forEach (name, word) ->
      val node = nodeMap[name]
      if (node == null)
      root.addElement(TAG_NAME)
      .addAttribute(“name”, name)
      .addText(word)
      else
      if (node.text != word)
      node.text = word



      outputStringFile(doc, stringFile)
      else
      // 创建新dom
      val langFile = File(parentDir, langDir)
      langFile.mkdirs()
      stringFile.createNewFile()
      val doc = DocumentHelper.createDocument()
      val root = doc.addElement(ROOT_TAG)
      hashMap.forEach (name, word) ->
      val element = root.addElement(TAG_NAME)
      element.addAttribute(“name”, name)
      .addText(word)

      outputStringFile(doc, stringFile)



    
    

    /**

    • 输出string文件
      */
      private fun outputStringFile(doc: Document, file: File)
      // 遍历所有节点,移除掉原本的换行符节点,否则输出时会因为newlines多出换行符
      val root = doc.rootElement
      if (root.name == ROOT_TAG)
      val iterator = root.nodeIterator()
      while (iterator.hasNext())
      val element = iterator.next()
      if (element.nodeType == org.dom4j.Node.TEXT_NODE)
      if (element.text.isBlank())
      iterator.remove()




      // 输出
      val format = OutputFormat()
      format.encoding = “utf-8”
      format.setIndentSize(4)
      format.isNewLineAfterDeclaration = false
      format.isNewlines = true
      format.lineSeparator = System.getProperty(“line.separator”)
      file.outputStream().use os ->
      val writer = XMLWriter(os, format)
      // 是否将字符转义
      writer.isEscapeText = false
      writer.write(doc)
      writer.flush()
      writer.close()

    
    

遇到过的问题

内容相同但是name不同的string如何处理

项目中总会因为这样或者那样的原因包含不少这样的字符,通常也会尽量不去改动,但是导出翻译时多一条重复的文案意味着白白多一份钱,所以最好还是要根据内容做去重。然后导入的时候,一旦匹配到项目中的某个string name,就再去寻找和它的内容相同的其他string,然后将它们全都覆盖为excel中的新值,这样问题也就解决了。

收集项目字符的问题

最开始收集string是使用的单行正则匹配进行的,但是这样明显有问题,一旦string标签不在同一行中就无法匹配,所以改为dom4j xml文档解析

输出文件格式问题

考虑输出时尽量保持源文件最小改动,使用dom4j的OutputFormat进行xml格式化时,如果是基于原文档修改,并且isNewlines=true时会多出很多空行,这是因为在dom4j中换行符也被视为一个Node,原本每一行后面有一个换行符,设置isNewlines后再次加了一个换行,所以需要先把原本的换行符移除掉

字符转义问题

英文单双引号等字符如果不进行转义就放在string中会被忽略掉,在UI上无法显示,所以在读取excel中字符时可以预先对这类字符做转义处理

以上是关于Android国际化翻译多国String导入导出(EXCEL)实现的主要内容,如果未能解决你的问题,请参考以下文章

Python android 多国翻译提取整合工具

Python android 多国翻译提取整合工具

开发Android studio 插件:项目国际化与Excel文件双向导入,并支持在线翻译。

msgfmt - 翻译汉化

Qt多国语言国际化

Odoo国际化翻译方法及示例介绍