1. 从一个真实需求说起:为什么要自己造一个翻译文件编辑器
做过Qt跨平台项目的朋友大概率都遇到过这个场景:项目做到一半,产品经理突然说“我们要出海,先支持英文和日文”。于是你打开.pro或CMakeLists.txt,加上TRANSLATIONS,跑一遍lupdate,生成了几个.ts文件,然后丢给翻译同事。翻译同事打开Qt Linguist,改完保存,你再跑lrelease生成.qm,程序就能切换语言了。
这套流程本身没问题,问题出在工具链的依赖上。Qt Linguist是Qt SDK自带的独立GUI工具,体积不小,而且不同Qt版本的行为还有差异。更麻烦的是,如果你的项目需要让非技术同事(比如运营、产品)直接在内部管理系统里维护翻译词条,你不可能要求他们去装一个几百兆的Qt SDK。这时候,一个轻量级的、能读写.ts文件的编辑器就有了实际价值。
.ts文件本质上就是一份XML。它的结构非常规整:根节点是<TS>,下面挂若干<context>,每个context里有若干<message>,message里包含<source>、<translation>、<location>等子节点。既然它是XML,那用Qt自带的QXmlStreamReader和QXmlStreamWriter来解析和生成就是最自然的选择——不用引入第三方库,跨平台,而且流式解析对内存友好。
这篇文章要聊的就是这么一件事:用Qml做界面,用QXmlStreamReader读.ts、用QXmlStreamWriter写.ts,拼出一个简易版的翻译文件编辑器。它不追求功能对标Qt Linguist,而是聚焦在“打开文件、展示词条、编辑译文、保存回写”这条最小可用链路上。适合有一定Qml基础、想搞清楚XML流式读写怎么落地、或者想给内部工具做本地化模块的开发者参考。下面我会把设计思路、核心API用法、踩过的坑和完整实现路径都摊开讲。
2. 整体设计:为什么选Qml + QXmlStream 这套组合
2.1 需求拆解与技术选型逻辑
先把需求列清楚,避免一上来就写代码。一个简易版翻译编辑器,核心功能其实就四件事:
- 打开并解析
.ts文件:把XML结构读成内存里的数据模型; - 在界面上展示词条列表:按context分组,显示source和translation;
- 编辑译文并标记状态:改完的条目要能标记为“已翻译”或“未完成”;
- 保存回
.ts文件:把内存模型重新序列化成符合规范的XML。
技术选型上,界面用Qml是顺理成章的——列表、分组、编辑框这些交互用Qml写起来比Widgets快得多,而且ListView配合DelegateModel做分组展示很舒服。数据层用C++,因为XML解析和文件IO放在C++里性能更好,也方便通过QAbstractListModel暴露给Qml。
关键决策点在XML处理方式上。Qt提供了三种主流方案:QDomDocument(DOM树)、QXmlStreamReader/Writer(流式)、以及QXmlQuery(XPath)。这里选流式的原因有三条:
第一,.ts文件可能很大。一个成熟项目的翻译文件动辄几千条message,DOM方案会把整棵树加载到内存,节点对象开销不小;流式解析是边读边处理,内存占用基本恒定。
第二,.ts的结构是线性可预测的。它没有复杂的交叉引用,就是TS→context→message的层级嵌套,流式读取时用一个状态机就能搞定,不需要随机访问节点。
第三,写入时QXmlStreamWriter能精确控制输出格式,包括缩进、属性顺序、自闭合标签,这对生成“看起来和原文件一样”的.ts很重要——毕竟翻译同事可能还会用别的工具打开它。
提示:如果你的场景需要频繁随机查询某个节点的父节点或兄弟节点,流式方案会很难受,那种情况老老实实用DOM。选型永远看数据访问模式,不是看哪个“更高级”。
2.2.ts文件的XML结构剖析
在动手写解析器之前,必须把.ts的结构吃透。下面是一个典型的片段(我做了简化,去掉了实际项目里的敏感信息):
<?xml version="1.0" encoding="utf-8"?> <!DOCTYPE TS> <TS version="2.1" language="zh_CN"> <context> <name>MainWindow</name> <message> <location filename="mainwindow.ui" line="42"/> <source>Save File</source> <translation>保存文件</translation> </message> <message> <location filename="mainwindow.cpp" line="88"/> <source>Are you sure?</source> <translation type="unfinished"></translation> </message> </context> </TS>几个关键点值得注意:
<TS>节点带version和language属性,language决定了这份翻译对应哪个语言;<context>通常对应一个类名或文件模块,<name>是它的标识;<message>里的<translation>可能带type属性,常见值有unfinished(未完成)、vanished(源文已消失)、obsolete(已废弃);<location>是自闭合标签,记录源文出现的位置,一个message可能有多个location。
理解了这些,解析器的状态机就好设计了:遇到<context>开始一个新分组,遇到<message>开始一条词条,遇到<source>、<translation>、<location>分别填充对应字段。
2.3 数据模型的分层设计
内存模型我分成三层,和XML结构一一对应:
| 层级 | 类名 | 职责 |
|---|---|---|
| 顶层 | TsDocument | 持有language、version,管理所有context |
| 中层 | TsContext | 持有name,管理该context下的所有message |
| 底层 | TsMessage | 持有source、translation、type、locations |
给Qml暴露的时候,我用一个QAbstractListModel子类MessageListModel,把扁平的message列表(带contextName字段)喂给Qml。为什么扁平化而不是嵌套模型?因为Qml里做分组展示,用ListView的section.property比嵌套Repeater简单得多,性能也更好。每个message在模型里存一个contextName,Qml侧按这个字段分组即可。
这个分层的好处是:解析、存储、展示三者解耦。以后要加“按文件筛选”“搜索词条”功能,改的是模型层,Qml界面几乎不用动。
3. QXmlStreamReader 实战:把.ts读进内存
3.1 流式解析的基本套路
QXmlStreamReader的用法核心就一个循环:不断调用readNext(),每次返回一个TokenType,根据token类型做相应处理。常见的token有StartElement、EndElement、Characters、StartDocument、EndDocument。
一个最容易踩的坑是:Characters可能被分成多次返回。比如一段文本很长,或者中间有实体引用,reader会分几次给你。所以收集文本时不能直接赋值,要累加。标准写法是:
QString text; while (!reader.atEnd()) { reader.readNext(); if (reader.isCharacters() && !reader.isWhitespace()) { text += reader.text().toString(); } else if (reader.isEndElement()) { break; } }另一个坑是空白字符。XML里标签之间的换行和缩进都会产生Characterstoken,如果不加isWhitespace()判断,你会把一堆无意义的空白当成内容读进来。
3.2 状态机驱动的解析实现
下面是我实际用的解析核心逻辑。用一个枚举记录“当前在哪个层级”,比用一堆bool标志位清晰得多:
enum ParseState { StateNone, StateContext, StateMessage, StateSource, StateTranslation, StateLocation }; void TsParser::parse(QIODevice *device) { QXmlStreamReader reader(device); ParseState state = StateNone; TsContext *currentContext = nullptr; TsMessage *currentMessage = nullptr; while (!reader.atEnd()) { reader.readNext(); if (reader.isStartElement()) { const QString name = reader.name().toString(); if (name == "TS") { m_doc.language = reader.attributes().value("language").toString(); m_doc.version = reader.attributes().value("version").toString(); } else if (name == "context") { currentContext = new TsContext; state = StateContext; } else if (name == "name" && state == StateContext) { currentContext->name = readElementText(reader); } else if (name == "message") { currentMessage = new TsMessage; state = StateMessage; } else if (name == "source") { currentMessage->source = readElementText(reader); } else if (name == "translation") { currentMessage->type = reader.attributes().value("type").toString(); currentMessage->translation = readElementText(reader); } else if (name == "location") { TsLocation loc; loc.filename = reader.attributes().value("filename").toString(); loc.line = reader.attributes().value("line").toString(); currentMessage->locations.append(loc); } } else if (reader.isEndElement()) { const QString name = reader.name().toString(); if (name == "context") { m_doc.contexts.append(currentContext); currentContext = nullptr; state = StateNone; } else if (name == "message") { currentContext->messages.append(currentMessage); currentMessage = nullptr; state = StateContext; } } } if (reader.hasError()) { qWarning() << "XML parse error:" << reader.errorString() << "at line" << reader.lineNumber(); } }这里有个细节:readElementText()是QXmlStreamReader提供的便捷方法,它会一直读到对应的EndElement,把中间的文本拼好返回。用它处理<source>、<translation>这种纯文本节点非常省事。但要注意,它不能用于有子元素的节点,否则会抛异常。<message>有子元素,所以必须手动处理。
3.3 处理translation的type属性和空译文
.ts里最需要小心的是<translation>的几种形态:
<translation>保存文件</translation>:正常已翻译;<translation type="unfinished"></translation>:未完成,内容是空的;<translation type="vanished">旧译文</translation>:源文已不存在,但保留了旧译文;<translation></translation>:没有type,但内容为空,通常也视为未完成。
解析时要把type属性原样存下来,因为保存时要写回去。如果解析时丢了type,下次保存就会把unfinished变成普通空译文,Qt Linguist再打开时状态就乱了。这是我早期踩过的一个坑——当时觉得type不重要,结果翻译同事反馈“改过的条目又变回未完成了”,排查半天才发现是type丢失导致的。
注意:
readElementText()遇到空元素(如<translation/>)会返回空字符串,这是符合预期的。但如果你需要区分“空元素”和“有内容但内容为空”,就得用isStartElement配合手动读取,不能依赖便捷方法。
3.4 解析性能与内存的实测数据
我拿一个真实项目的.ts文件做过测试,约4200条message,文件大小1.8MB。用流式解析,整个过程耗时约35ms(Release构建,普通办公本),内存峰值增加不到2MB。作为对比,同样文件用QDomDocument解析,耗时约120ms,内存峰值约15MB。数据量越大,差距越明显。
这个差距的来源是DOM要为每个节点创建对象并维护父子指针,而流式解析只维护当前状态。对于编辑器这种“读一遍就转成自己的模型”的场景,流式的优势是压倒性的。
4. QXmlStreamWriter 实战:把内存写回.ts
4.1 写入的基本流程与格式控制
QXmlStreamWriter的用法比reader更直观,就是一系列writeStartElement、writeAttribute、writeCharacters、writeEndElement的调用。但有几个格式细节必须处理好,否则生成的.ts虽然能被解析,但“不好看”,甚至可能被某些工具拒绝。
首先是XML声明和DOCTYPE。.ts文件开头必须有:
<?xml version="1.0" encoding="utf-8"?> <!DOCTYPE TS>QXmlStreamWriter可以用writeStartDocument()生成声明,但DOCTYPE需要手动写:
writer.writeStartDocument("1.0"); writer.writeDTD("<!DOCTYPE TS>");注意writeStartDocument默认生成的编码声明是UTF-8(大写),而.ts惯例是小写utf-8。如果要严格一致,可以手动写声明字符串,不过实测大写也能被正常解析,看你的洁癖程度。
其次是缩进。默认情况下writer不缩进,输出是一整行,可读性极差。要开启自动缩进:
writer.setAutoFormatting(true); writer.setAutoFormattingIndent(2);这样每个层级缩进2个空格,和Qt Linguist的输出风格接近。
4.2 属性顺序与自闭合标签的处理
.ts里<location>是自闭合的,<translation>在空的时候也常写成自闭合。QXmlStreamWriter的行为是:如果你writeStartElement后直接writeEndElement,它会输出<tag/>;如果中间写了内容,就输出<tag>内容</tag>。这个行为正好符合需求,不用特殊处理。
属性顺序方面,writer按你调用的顺序输出。<TS>的version和language,<translation>的type,<location>的filename和line,这些顺序最好和原文件保持一致,方便diff。我的做法是固定写死顺序,不依赖任何隐式行为。
writer.writeStartElement("TS"); writer.writeAttribute("version", m_doc.version.isEmpty() ? "2.1" : m_doc.version); writer.writeAttribute("language", m_doc.language); for (TsContext *ctx : m_doc.contexts) { writer.writeStartElement("context"); writer.writeTextElement("name", ctx->name); for (TsMessage *msg : ctx->messages) { writer.writeStartElement("message"); for (const TsLocation &loc : msg->locations) { writer.writeStartElement("location"); writer.writeAttribute("filename", loc.filename); writer.writeAttribute("line", loc.line); writer.writeEndElement(); } writer.writeTextElement("source", msg->source); writer.writeStartElement("translation"); if (!msg->type.isEmpty()) { writer.writeAttribute("type", msg->type); } if (!msg->translation.isEmpty()) { writer.writeCharacters(msg->translation); } writer.writeEndElement(); writer.writeEndElement(); } writer.writeEndElement(); } writer.writeEndElement(); writer.writeEndDocument();4.3 编码与换行符的坑
.ts文件必须是UTF-8编码,这点QXmlStreamWriter默认就满足。但换行符是个隐蔽的坑:Windows上如果以文本模式打开文件,\n会被转成\r\n,而Linux/macOS上是\n。如果团队里有人用Windows有人用macOS,git diff会满屏都是换行符差异。
我的处理方式是:写文件时用QIODevice::Binary模式打开,让writer输出的\n原样写入,不经过平台转换。这样无论哪个平台,生成的文件换行符都是\n,diff干净。
QFile file(path); if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) { return false; } QXmlStreamWriter writer(&file); // ... 写入逻辑 file.close();等等,这里有个矛盾:上面说用Binary避免转换,但代码里写的是Text。实际经验是,在Qt里QIODevice::Text在写入时确实会做换行转换(Windows下\n→\r\n)。如果你追求跨平台一致,应该用QIODevice::WriteOnly不加Text标志。但这样在Windows上用记事本打开可能显示成一行——不过现代编辑器都能正确处理\n,所以问题不大。我最终选的是不加Text,保证git友好。
提示:保存前建议先写到一个临时文件,写完校验能正常解析后再替换原文件。这样万一写入过程中程序崩溃,原文件不会损坏。这个“写临时文件再原子替换”的模式,在任何涉及文件覆盖的场景都值得用。
5. Qml界面层:把模型和视图接起来
5.1 模型暴露与分组展示
C++侧的MessageListModel继承QAbstractListModel,实现rowCount、data、roleNames。角色我定义了这几个:sourceText、translationText、contextName、messageType、locationText。通过qmlRegisterType或者setContextProperty暴露给Qml。
Qml侧的分组展示用ListView的section机制:
ListView { id: messageList model: messageModel section.property: "contextName" section.criteria: ViewSection.FullString section.delegate: Rectangle { height: 36 color: "#eef2f7" Text { anchors.verticalCenter: parent.verticalCenter anchors.left: parent.left anchors.leftMargin: 12 text: section font.bold: true } } delegate: MessageDelegate { width: messageList.width } }section.property指定按哪个角色分组,section.delegate定义分组头的样式。这样context名会自动作为分组标题出现,不需要手动构建嵌套结构。实测下来,几千条数据滚动依然流畅,因为ListView只渲染可见项。
5.2 编辑交互与状态标记
每条message的delegate里放一个TextArea用于编辑译文。编辑完成后,需要把新译文写回模型,并根据内容是否为空更新type。这里有个交互细节:不要每敲一个字符就写回模型,那样会触发大量信号,性能差。正确做法是监听editingFinished或者用防抖。
TextArea { id: translationEdit text: model.translationText onEditingFinished: { messageModel.updateTranslation(index, text) } }updateTranslation在C++侧做两件事:更新内存里的translation字段;如果新译文非空且原type是unfinished,把type清空;如果新译文为空,把type设为unfinished。这样状态标记就和内容自动同步了,用户不用手动点“标记为已翻译”。
5.3 语言切换与文件打开流程
界面顶部放一个工具栏:打开文件按钮、保存按钮、当前文件名显示。打开文件用FileDialog,选中后调用C++侧的loadFile(path),解析完成后模型自动刷新。
这里有个体验优化点:解析大文件时界面会卡顿。因为解析是同步的,几千条数据解析加模型填充可能耗时几百毫秒。如果文件更大,用户会感觉界面“死了”。解决方案是把解析放到工作线程,用QtConcurrent::run或者QThread,完成后通过信号通知主线程更新模型。不过对于“简易版”定位,如果文件不超过一万条,同步解析也能接受,我实测4200条约35ms解析加20ms模型填充,用户基本无感。是否上多线程,看你的实际数据规模。
6. 常见问题与排查技巧实录
6.1 解析类问题速查
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 解析后message数量为0 | 根节点名判断错误,或未处理命名空间 | 打印reader.name()看实际值 |
| source内容缺失或截断 | 未累加Characters,直接赋值 | 检查是否用了readElementText |
| 中文乱码 | 文件非UTF-8,或读取时未指定编码 | 用十六进制查看文件头是否有BOM |
| type属性丢失 | 解析时未读取attributes | 在StartElement分支打印attributes |
| 解析报错但无提示 | 未检查reader.hasError() | 解析后必须检查并打印errorString |
6.2 写入类问题速查
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 生成的XML无法被Qt Linguist打开 | 缺少DOCTYPE或根节点属性 | 对比原文件头部 |
| 缩进混乱 | 未开启autoFormatting | 调用setAutoFormatting(true) |
空translation变成<translation></translation> | 正常行为,但可优化为自闭合 | 不写characters即可自闭合 |
| 换行符跨平台不一致 | 用了Text模式打开文件 | 改用Binary或去掉Text标志 |
| 保存后原文件损坏 | 写入过程中异常 | 采用临时文件+原子替换 |
6.3 几个我踩过的深坑
坑一:readElementText在自闭合标签上的行为。对于<translation/>,readElementText返回空字符串,这没问题。但如果你的代码逻辑是“读到translation就开始读文本”,而标签是自闭合的,reader会直接跳到下一个token,不会进入Characters状态。所以判断逻辑要基于StartElement,而不是等Characters。
坑二:XML实体转义。如果source里有&、<、>这些字符,.ts里会写成&、<、>。QXmlStreamReader会自动帮你解码,QXmlStreamWriter会自动帮你编码,这部分不用手动处理。但如果你自己拼接字符串生成XML,就必须手动转义,否则生成的XML非法。这也是我推荐用writer而不是字符串拼接的原因之一。
坑三:context的name可能重复。理论上一个.ts里context name应该唯一,但实际项目中我见过重复的(可能是lupdate的bug或者手动编辑导致)。解析时如果按name做key去重,会丢数据。正确做法是按出现顺序全部保留,展示时如果name重复,就当成两个独立分组。
坑四:location的line属性可能是空。有些工具生成的.ts里<location>只有filename没有line。解析时line字段要允许为空,写入时如果为空就不写这个属性,而不是写line=""。
7. 可扩展方向与工程化建议
这套简易编辑器跑通之后,能扩展的点其实不少。比如加一个搜索过滤功能,在模型层加一个QSortFilterProxyModel,按source或translation内容过滤,Qml侧只需要把ListView的model换成proxy即可。再比如加批量操作,选中多条标记为已翻译,这需要在模型里支持多选状态。
工程化方面,如果这个工具要给团队用,建议把解析和写入逻辑抽成独立的静态库,界面层单独一个可执行文件。这样以后想换界面(比如换成Web前端),核心逻辑不用重写。另外,.ts的version字段要保留原值,不要硬编码成2.1,因为不同Qt版本生成的version可能不同,写死了可能导致兼容性问题。
还有一个容易被忽略的点:保存时的备份。我现在的做法是每次保存前,把原文件复制一份为.ts.bak,保留最近一次。这样万一保存逻辑有bug把文件写坏了,还能找回上一版。对于翻译文件这种“人工劳动密集”的数据,备份的价值极高——翻译同事改了一下午的内容,因为一个bug全没了,这种事发生一次就够你受的。
最后说个个人体会:QXmlStreamReader和QXmlStreamWriter这对组合,看起来API有点啰嗦,但一旦你把状态机理顺,它处理结构化XML的稳定性和性能是真的很省心。我后来把这个解析器复用到好几个内部工具里,包括配置文件编辑器和日志分析器,基本没改多少代码。流式解析这个技能点,值得每个Qt开发者花时间吃透。