
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里有若干messagemessage里包含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提供了三种主流方案QDomDocumentDOM树、QXmlStreamReader/Writer流式、以及QXmlQueryXPath。这里选流式的原因有三条第一.ts文件可能很大。一个成熟项目的翻译文件动辄几千条messageDOM方案会把整棵树加载到内存节点对象开销不小流式解析是边读边处理内存占用基本恒定。第二.ts的结构是线性可预测的。它没有复杂的交叉引用就是TS→context→message的层级嵌套流式读取时用一个状态机就能搞定不需要随机访问节点。第三写入时QXmlStreamWriter能精确控制输出格式包括缩进、属性顺序、自闭合标签这对生成“看起来和原文件一样”的.ts很重要——毕竟翻译同事可能还会用别的工具打开它。提示如果你的场景需要频繁随机查询某个节点的父节点或兄弟节点流式方案会很难受那种情况老老实实用DOM。选型永远看数据访问模式不是看哪个“更高级”。2.2.ts文件的XML结构剖析在动手写解析器之前必须把.ts的结构吃透。下面是一个典型的片段我做了简化去掉了实际项目里的敏感信息?xml version1.0 encodingutf-8? !DOCTYPE TS TS version2.1 languagezh_CN context nameMainWindow/name message location filenamemainwindow.ui line42/ sourceSave File/source translation保存文件/translation /message message location filenamemainwindow.cpp line88/ sourceAre you sure?/source translation typeunfinished/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在模型里存一个contextNameQml侧按这个字段分组即可。这个分层的好处是解析、存储、展示三者解耦。以后要加“按文件筛选”“搜索词条”功能改的是模型层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 typeunfinished/translation未完成内容是空的translation typevanished旧译文/translation源文已不存在但保留了旧译文translation/translation没有type但内容为空通常也视为未完成。解析时要把type属性原样存下来因为保存时要写回去。如果解析时丢了type下次保存就会把unfinished变成普通空译文Qt Linguist再打开时状态就乱了。这是我早期踩过的一个坑——当时觉得type不重要结果翻译同事反馈“改过的条目又变回未完成了”排查半天才发现是type丢失导致的。注意readElementText()遇到空元素如translation/会返回空字符串这是符合预期的。但如果你需要区分“空元素”和“有内容但内容为空”就得用isStartElement配合手动读取不能依赖便捷方法。3.4 解析性能与内存的实测数据我拿一个真实项目的.ts文件做过测试约4200条message文件大小1.8MB。用流式解析整个过程耗时约35msRelease构建普通办公本内存峰值增加不到2MB。作为对比同样文件用QDomDocument解析耗时约120ms内存峰值约15MB。数据量越大差距越明显。这个差距的来源是DOM要为每个节点创建对象并维护父子指针而流式解析只维护当前状态。对于编辑器这种“读一遍就转成自己的模型”的场景流式的优势是压倒性的。4. QXmlStreamWriter 实战把内存写回.ts4.1 写入的基本流程与格式控制QXmlStreamWriter的用法比reader更直观就是一系列writeStartElement、writeAttribute、writeCharacters、writeEndElement的调用。但有几个格式细节必须处理好否则生成的.ts虽然能被解析但“不好看”甚至可能被某些工具拒绝。首先是XML声明和DOCTYPE。.ts文件开头必须有?xml version1.0 encodingutf-8? !DOCTYPE TSQXmlStreamWriter可以用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和languagetranslation的typelocation的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有人用macOSgit diff会满屏都是换行符差异。我的处理方式是写文件时用QIODevice::Binary模式打开让writer输出的\n原样写入不经过平台转换。这样无论哪个平台生成的文件换行符都是\ndiff干净。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或读取时未指定编码用十六进制查看文件头是否有BOMtype属性丢失解析时未读取attributes在StartElement分支打印attributes解析报错但无提示未检查reader.hasError()解析后必须检查并打印errorString6.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里会写成amp;、lt;、gt;。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开发者花时间吃透。