前言

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

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

使用方法

仓库地址

  1. clone项目到本地,elements修改module_strelement滑板ing_script模块中Config类的配置

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

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

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

  4. 运行ImportFromEappearxcelScri数据结构课程设计心得体会pt.kt把翻译完的excel内容导入到项数据结构课程设计心得体会目里面

实现过程和遇到的问题

导出

  1. 遍历项目elementary每个模块,使用dom4j库解各国语言的strielementsng.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. 转换element滑板据结构,把<语言目录elementary翻译(如values-zh-rCN),<name,word>>结构转换成<name,<语言目录,word>approach>结构,方便后续操作

  3. 根据策略对解析到的内容做处理,可以把相同内容但是不同naelementanimationme的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,<语言目录,值>&gappointmentt;>
    /**
     * 获取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<String>()
        //获取工作簿
        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. 再次读取项目中所有element酒店模块的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 (resData.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:${pair.first} mapping old names:${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: $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,使用doapplicationm4j修改或者创建原本项目中对应的string.xml文件
    /**
     * 将excel中string导入到项目
     */
    fun importWords(newLangNameMap: LinkedHashMap<String, LinkedHashMap<String, String>>, parentDir: File) {
        newLangNameMap.forEach { (langDir, hashMap) ->
            Log.e(TAG, "import lang dir $langDir")
            if (langDir.startsWith("values")) {
                val stringFile = File(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不同的strinappleg如何处理

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

收集项目字符的问题

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

输出文件格式问题

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

字符转义问题

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