首页 / 资讯中心 / 文章详情

基于Qml与QXmlStream的Qt翻译文件编辑器实战

基于Qml与QXmlStream的Qt翻译文件编辑器实战 ★ FEATURED ARTICLE
1. 从翻译文件到可视化编辑器这个工具到底要解决什么问题做过Qt桌面开发的人大概率都碰过这样的场景项目做到一半产品经理突然说要加两门语言于是你打开.ts文件发现里面密密麻麻全是context、message、source、translation标签手动改起来眼睛都要瞎掉。更麻烦的是翻译同事拿到这个文件之后完全不知道从哪下手你只能截图加标注来回沟通成本极高。Qt Linguist本身是个好工具但它有个问题——它是给专业翻译人员用的功能全但界面复杂而且你没法把它嵌到自己的项目里做定制化流程。比如你想在CI里自动检查哪些翻译还没完成或者想给翻译人员提供一个只显示待翻译条目的精简界面Qt Linguist就帮不上忙了。所以这个项目的核心目标就很明确了用Qml做一个简易版的翻译文件编辑器能够读取.ts文件、展示翻译条目、编辑翻译内容、保存回.ts文件。整个过程中最关键的技术点就是QXmlStreamReader和QXmlStreamWriter这两个类——前者负责把XML格式的.ts文件解析成内存中的数据结构后者负责把编辑后的数据重新序列化成合法的XML文件。这个工具适合谁参考如果你正在做Qt相关的桌面应用需要自己处理翻译文件的读写或者你单纯想学QXmlStreamReader/QXmlStreamWriter的实战用法不想看那些只解析个booktitle的玩具示例那这篇内容应该能给你不少可直接复用的代码和踩坑经验。.ts文件的本质就是XML但它的结构比一般XML要复杂一些——有嵌套的context、message还有plural、numerus这些特殊标记。用DOM解析当然也能做但文件一大内存就吃不消用正则更是自找麻烦。QXmlStreamReader的流式解析方式在这个场景下是最合适的内存占用小而且Qt原生支持不需要引入第三方库。2. 拆解.ts文件结构QXmlStreamReader到底要读什么2.1 一个典型.ts文件的骨架长什么样在动手写解析代码之前得先把.ts文件的结构摸清楚。很多人上来就开始写readNext()循环结果读到一半发现漏了context的嵌套关系或者把translation的type属性给丢了。我建议你先拿一个真实的.ts文件用文本编辑器打开把结构画出来再动手。一个标准的.ts文件大概是这样?xml version1.0 encodingutf-8? !DOCTYPE TS TS version2.1 languagezh_CN context nameMainWindow/name message location filenamemainwindow.ui line14/ sourceFile/source translation文件/translation /message message numerusyes source%n file(s)/source translation numerusform%n 个文件/numerusform numerusform%n 个文件/numerusform /translation /message /context /TS这里有几个关键点需要注意。TS根节点带version和language属性这两个在保存时必须原样写回去否则Qt Linguist打开可能会报版本不兼容。context通常对应一个类名或者UI文件一个.ts文件里可以有多个context。message是最小翻译单元它可能包含location标记这个字符串在源码中的位置、source原始文本、comment给翻译者的注释、translation译文。translation这个节点是最容易踩坑的地方。它有两种形态普通情况下直接包含文本内容当message带有numerusyes属性时translation里面会嵌套多个numerusform子节点分别对应单复数形式。如果你解析的时候不判断这个复数翻译就会丢数据。还有一个细节translation可能带有type属性常见的有unfinished未完成、vanished源字符串已不存在、obsolete已废弃。这些状态在编辑器里要能区分显示不然翻译人员根本不知道哪些需要处理。2.2 用QXmlStreamReader逐层读取的正确姿势QXmlStreamReader的工作方式像一个游标你不断调用readNext()让它往前走每走一步通过tokenType()判断当前遇到的是什么——StartElement、EndElement、Characters、Comment等等。听起来简单但实际写起来最容易犯的错误就是状态管理混乱。我的做法是维护一个状态栈用一个QStringList记录当前所处的元素路径。比如读到TS时压入TS读到context时压入context读到message时压入message。这样在任何时候我都能通过栈顶知道自己在哪一层该做什么处理。QXmlStreamReader reader(file); QStringList elementStack; TsContext currentContext; TsMessage currentMessage; bool inTranslation false; while (!reader.atEnd()) { reader.readNext(); if (reader.isStartElement()) { QString name reader.name().toString(); elementStack.append(name); if (name TS) { m_version reader.attributes().value(version).toString(); m_language reader.attributes().value(language).toString(); } else if (name context) { currentContext TsContext(); } else if (name message) { currentMessage TsMessage(); currentMessage.numerus (reader.attributes().value(numerus) yes); } else if (name translation) { inTranslation true; currentMessage.translationType reader.attributes().value(type).toString(); } } else if (reader.isEndElement()) { QString name reader.name().toString(); elementStack.removeLast(); if (name context) { m_contexts.append(currentContext); } else if (name message) { currentContext.messages.append(currentMessage); } else if (name translation) { inTranslation false; } } else if (reader.isCharacters() !reader.isWhitespace()) { QString text reader.text().toString(); if (elementStack.last() name) { currentContext.name text; } else if (elementStack.last() source) { currentMessage.source text; } else if (elementStack.last() translation inTranslation) { currentMessage.translation text; } else if (elementStack.last() numerusform) { currentMessage.numerusForms.append(text); } } }这段代码看起来直白但有几个地方值得展开说。第一isWhitespace()的判断很重要——XML文件里元素之间通常有换行和缩进这些空白字符也会触发isCharacters()如果不排除掉你的source字段里就会混入一堆空格和换行。第二elementStack的压栈和弹栈必须严格配对readNext()遇到StartElement就压遇到EndElement就弹这样elementStack.last()永远指向当前正在处理的元素。第三location元素我上面没处理但实际项目中它是需要的——翻译人员看到mainwindow.ui 第14行能更快定位上下文。location是个自闭合标签在QXmlStreamReader里它表现为一个StartElement紧接着一个EndElement你需要在StartElement时读取它的filename和line属性。2.3 处理numerus复数形式和translation状态复数形式的处理是.ts解析里最容易被忽略的部分。当message带有numerusyes时translation下面会有多个numerusform数量取决于目标语言——中文只有一种形式英文两种阿拉伯语可能有六种。你在解析时不能假设只有一两个得用一个列表全部收进来。} else if (name numerusform) { currentMessage.numerusForms.append(reader.text().toString()); }注意这里我是在StartElement分支里读的文本实际上更稳妥的做法是在Characters分支里判断elementStack.last() numerusform。因为numerusform的文本内容可能被XML实体分割成多个Characters事件比如包含amp;的时候。QXmlStreamReader会自动帮你解码实体但会分成多次isCharacters()回调所以你需要累积而不是直接赋值。translation的type属性也要留意。如果typeunfinished说明这条翻译还没完成如果typevanished说明源码里已经找不到这个字符串了但翻译文件里还留着。在编辑器里我建议用不同的背景色来区分这些状态翻译人员一眼就能看出哪些需要优先处理。还有一个边界情况translation可能是自闭合的也就是translation/表示空翻译。在QXmlStreamReader里自闭合标签会触发一次StartElement和一次EndElement但不会有Characters事件。所以你的currentMessage.translation会保持为空字符串这是正确的行为。3. 把内存数据写回.ts文件QXmlStreamWriter的细节陷阱3.1 写XML不是简单拼字符串解析完了用户在界面上编辑了翻译接下来要把数据写回.ts文件。这时候QXmlStreamWriter就派上用场了。很多人觉得写XML有什么难的直接QString拼接不就行了我劝你千万别这么干——XML的转义规则比你想象的复杂、、、、这些字符在不同位置有不同的转义要求手动处理迟早出bug。QXmlStreamWriter的好处是它帮你处理了所有转义和格式化问题。你只需要按顺序调用writeStartElement()、writeCharacters()、writeEndElement()它就会生成合法的XML。QFile file(filePath); file.open(QIODevice::WriteOnly | QIODevice::Text); QXmlStreamWriter writer(file); writer.setAutoFormatting(true); writer.writeStartDocument(); writer.writeDTD(!DOCTYPE TS); writer.writeStartElement(TS); writer.writeAttribute(version, m_version); writer.writeAttribute(language, m_language); for (const TsContext ctx : m_contexts) { writer.writeStartElement(context); writer.writeTextElement(name, ctx.name); for (const TsMessage msg : ctx.messages) { writer.writeStartElement(message); if (msg.numerus) { writer.writeAttribute(numerus, yes); } for (const TsLocation loc : msg.locations) { writer.writeStartElement(location); writer.writeAttribute(filename, loc.filename); writer.writeAttribute(line, QString::number(loc.line)); writer.writeEndElement(); } writer.writeTextElement(source, msg.source); if (!msg.comment.isEmpty()) { writer.writeTextElement(comment, msg.comment); } if (msg.numerus) { writer.writeStartElement(translation); for (const QString form : msg.numerusForms) { writer.writeTextElement(numerusform, form); } writer.writeEndElement(); } else { writer.writeStartElement(translation); if (!msg.translationType.isEmpty()) { writer.writeAttribute(type, msg.translationType); } writer.writeCharacters(msg.translation); writer.writeEndElement(); } writer.writeEndElement(); // message } writer.writeEndElement(); // context } writer.writeEndElement(); // TS writer.writeEndDocument(); file.close();3.2 writeTextElement和writeCharacters的区别上面代码里我混用了writeTextElement()和writeCharacters()这两个的区别值得单独说一下。writeTextElement(source, msg.source)等价于writeStartElement(source)writeCharacters(msg.source)writeEndElement()是一个语法糖。当元素只有纯文本内容、没有属性也没有子元素时用它最方便。但translation就不行了因为它可能有type属性所以必须先writeStartElement(translation)然后writeAttribute()再writeCharacters()最后writeEndElement()。顺序不能乱——writeAttribute()必须在writeCharacters()之前调用否则属性会被写到元素内容后面生成的XML就是非法的。还有一个坑writeCharacters()会自动转义特殊字符但如果你传入的字符串里包含XML不支持的字符比如某些控制字符它不会报错而是静默丢弃。所以如果你的翻译文本来自用户输入最好在写入前做一次清洗把不可见控制字符过滤掉。3.3 保持文件格式与Qt Linguist兼容生成的.ts文件最终是要给Qt Linguist或者lrelease用的所以格式必须兼容。有几个细节需要注意writeStartDocument()默认写入?xml version1.0 encodingUTF-8?但Qt Linguist生成的通常是?xml version1.0 encodingutf-8?大小写不同。虽然XML规范里编码名大小写不敏感但为了保险我建议手动写writer.writeStartDocument(1.0);然后自己控制编码声明。不过实测下来writeStartDocument()生成的UTF-8大写形式lrelease也能正常处理所以不是必须改。setAutoFormatting(true)会让输出的XML带缩进和换行可读性好但文件体积会大一些。如果你追求最小体积可以关掉它但那样生成的文件就是一行到底调试起来很痛苦。我的建议是开发阶段开着发布版本再考虑关掉。writeDTD(!DOCTYPE TS)这行不能漏。Qt Linguist在打开文件时会检查DTD声明没有的话虽然也能打开但可能会弹出警告。而且lrelease在处理时也依赖这个声明来识别文件类型。4. Qml界面与C后端的桥接设计4.1 用QAbstractListModel还是直接暴露QList解析出来的数据结构要展示在Qml界面上最直接的方式是把QListTsMessage暴露给Qml。但这样做有个问题Qml里的ListView需要的是QAbstractItemModel或者至少是一个QVariantList直接传QList自定义结构体是没法用的。我的做法是定义一个TranslationModel继承QAbstractListModel把每个TsMessage包装成一个model row。这样Qml的ListView可以直接绑定而且增删改查都能通过model的接口来做数据一致性有保障。class TranslationModel : public QAbstractListModel { Q_OBJECT public: enum Roles { SourceRole Qt::UserRole 1, TranslationRole, ContextRole, StatusRole, NumerusRole }; int rowCount(const QModelIndex parent QModelIndex()) const override { return m_messages.size(); } QVariant data(const QModelIndex index, int role) const override { if (!index.isValid() || index.row() m_messages.size()) return QVariant(); const TsMessage msg m_messages[index.row()]; switch (role) { case SourceRole: return msg.source; case TranslationRole: return msg.translation; case ContextRole: return msg.contextName; case StatusRole: return msg.translationType; case NumerusRole: return msg.numerus; default: return QVariant(); } } QHashint, QByteArray roleNames() const override { return { {SourceRole, source}, {TranslationRole, translation}, {ContextRole, contextName}, {StatusRole, status}, {NumerusRole, numerus} }; } Q_INVOKABLE void updateTranslation(int row, const QString text) { if (row 0 || row m_messages.size()) return; m_messages[row].translation text; m_messages[row].translationType.clear(); emit dataChanged(index(row), index(row), {TranslationRole, StatusRole}); } private: QListTsMessage m_messages; };roleNames()是Qml能访问model数据的关键。Qml里写model.source、model.translation底层就是通过role name去查对应的role id再调用data()。如果你忘了重写roleNames()Qml里就只能用model.display这种默认role拿不到自定义字段。Q_INVOKABLE标记的方法可以从Qml直接调用比如在TextField的onEditingFinished里调translationModel.updateTranslation(index, text)。注意更新数据后要发dataChanged信号否则Qml界面不会刷新。4.2 文件读写操作的异步处理打开和保存.ts文件可能涉及大文件IO如果在UI线程里同步做界面会卡住。我的做法是把解析和序列化放到QtConcurrent::run()里完成后通过信号通知Qml。void TsFileHandler::loadFileAsync(const QString path) { QtConcurrent::run([this, path]() { QListTsContext contexts; QString error; bool ok parseTsFile(path, contexts, error); QMetaObject::invokeMethod(this, [this, ok, contexts, error]() { if (ok) { m_model-setContexts(contexts); emit loadFinished(true, QString()); } else { emit loadFinished(false, error); } }, Qt::QueuedConnection); }); }这里用QMetaObject::invokeMethod把结果切回主线程因为model的更新必须在主线程做。Qt::QueuedConnection确保lambda在主线程的事件循环里执行。4.3 Qml端的界面布局与交互细节Qml端的界面我做得比较简洁顶部一个工具栏放打开、保存按钮中间一个ListView展示所有翻译条目每个条目左边显示source右边是一个可编辑的TextArea显示translation。底部放一个状态栏显示当前文件路径和翻译完成度。ListView { id: translationList model: translationModel delegate: Rectangle { width: parent.width height: Math.max(60, sourceText.implicitHeight translationEdit.implicitHeight 20) color: status unfinished ? #FFF8E1 : status vanished ? #FFEBEE : white Column { anchors.fill: parent anchors.margins: 8 spacing: 4 Text { id: sourceText text: model.source font.bold: true wrapMode: Text.Wrap width: parent.width } TextEdit { id: translationEdit text: model.translation wrapMode: TextEdit.Wrap width: parent.width onEditingFinished: { translationModel.updateTranslation(index, text) } } } } }背景色根据status区分是个很实用的小设计。unfinished用淡黄色vanished用淡红色正常完成的用白色。翻译人员扫一眼就知道哪些需要处理。onEditingFinished在TextEdit里是失去焦点时触发不是每次按键都触发。这样避免频繁调用updateTranslation导致性能问题。如果你想要实时保存可以监听textChanged但那样每次按键都会触发model更新和界面刷新大文件下会卡。5. 实测中遇到的坑与排查过程5.1 中文乱码编码声明与实际编码不一致第一次测试的时候打开一个包含中文翻译的.ts文件界面上显示的全是乱码。排查过程是这样的先确认文件本身的编码用十六进制编辑器打开发现文件头有BOM实际编码是UTF-8。但QXmlStreamReader读出来的文本却是乱码。问题出在QFile打开时没有指定编码。QXmlStreamReader默认按照XML声明里的encoding属性来解码但如果文件有BOMBOM会干扰解析。解决方案是在QFile打开后手动跳过BOMQFile file(path); file.open(QIODevice::ReadOnly | QIODevice::Text); QByteArray data file.readAll(); if (data.startsWith(\xEF\xBB\xBF)) { data.remove(0, 3); } QXmlStreamReader reader(data);或者更简单的方式用QTextStream设置编码后再交给QXmlStreamReader。但QXmlStreamReader的构造函数只接受QIODevice或QByteArray不接受QTextStream所以还是得手动处理BOM。另一个可能的原因是.ts文件声明的编码和实际编码不一致。有些工具生成的.ts文件声明是utf-8但实际保存成了GBK。这种情况下QXmlStreamReader会按UTF-8解码中文就乱了。排查方法是看文件头几个字节UTF-8的中文通常是E4-B8-AD这种三字节序列GBK是D6-D0这种两字节序列。5.2 保存后Qt Linguist打不开DTD和根元素属性丢失有一次保存完.ts文件用Qt Linguist打开直接报错。对比原始文件和生成文件发现两个问题一是!DOCTYPE TS声明丢了二是TS根元素的version属性变成了空字符串。第一个问题的原因是writeStartDocument()之后没有调writeDTD()。writeStartDocument()只写XML声明不写DTD。第二个问题的原因是解析时没有保存version属性或者保存了但在写入时用了错误的变量。修复方法是在解析时把version和language存到成员变量里写入时确保它们非空if (name TS) { m_version reader.attributes().value(version).toString(); if (m_version.isEmpty()) m_version 2.1; m_language reader.attributes().value(language).toString(); if (m_language.isEmpty()) m_language en; }给个默认值是个好习惯防止原始文件缺少属性导致生成的文件不合法。5.3 复数翻译丢失numerusform被当成普通文本测试复数翻译时发现保存后的文件里numerusform变成了translation的直接文本内容结构完全错了。排查后发现是写入逻辑的问题我在判断msg.numerus时用了msg.numerusForms.size() 0作为条件但有些复数消息的numerusForms可能为空翻译还没做这时候就会走到普通翻译的分支。正确的做法是判断msg.numerus这个布尔标志而不是看numerusForms是否为空if (msg.numerus) { writer.writeStartElement(translation); for (const QString form : msg.numerusForms) { writer.writeTextElement(numerusform, form); } writer.writeEndElement(); } else { // 普通翻译 }即使numerusForms为空只要numerus为true就应该写空的translation或者带typeunfinished的translation而不是写成普通文本形式。5.4 大文件加载卡顿一次性读取vs流式读取项目里有个.ts文件有上万条翻译用QXmlStreamReader流式读取本来应该很快但实际测试发现加载要好几秒。用性能分析工具一看瓶颈不在XML解析而在model的数据更新——每解析一条message就调一次beginInsertRows/endInsertRows上万次信号发射把UI线程拖垮了。解决方案是批量更新解析阶段先把所有数据存到一个QList里解析完成后再一次性设置给modelvoid TranslationModel::setMessages(const QListTsMessage messages) { beginResetModel(); m_messages messages; endResetModel(); }beginResetModel()/endResetModel()只发一次信号Qml的ListView会整体刷新。虽然会丢失滚动位置和选中状态但加载速度从几秒降到了几十毫秒这个取舍是值得的。如果确实需要保留滚动位置可以分批插入比如每500条调一次beginInsertRows/endInsertRows这样既有批量更新的性能优势又不会完全重置视图。6. 几个提升实用性的扩展思路6.1 翻译完成度统计与过滤在实际使用中翻译人员最关心的是还有多少没翻译。我在model里加了一个Q_INVOKABLE方法返回统计信息Q_INVOKABLE QVariantMap getStatistics() const { int total m_messages.size(); int unfinished 0, vanished 0, completed 0; for (const auto msg : m_messages) { if (msg.translationType unfinished) unfinished; else if (msg.translationType vanished) vanished; else if (!msg.translation.isEmpty()) completed; } return { {total, total}, {unfinished, unfinished}, {vanished, vanished}, {completed, completed}, {progress, total 0 ? (double)completed / total : 0.0} }; }Qml端用一个进度条绑定progress再显示已完成 120/500这样的文字。过滤功能可以通过QSortFilterProxyModel实现按status筛选只显示未完成的条目。6.2 用QXmlStreamWriter生成.lrelease中间文件除了.ts文件有时候还需要生成.qm文件。.qm是二进制格式不能直接用QXmlStreamWriter写。但你可以生成.ts之后调用lrelease命令行工具来转换QProcess process; process.start(lrelease, {tsFilePath, -qm, qmFilePath}); process.waitForFinished();这个思路在CI流程里很有用——自动拉取最新的.ts文件检查完成度达标后自动生成.qm并打包。6.3 支持批量替换和查找翻译条目多了之后查找和批量替换是刚需。Qml的ListView本身不支持搜索但你可以用QSortFilterProxyModel加一个filterString属性在C端实现过滤逻辑。批量替换则是在model里加一个replaceAll(sourceText, targetText)方法遍历所有条目做替换。这里有个性能注意点批量替换后不要逐条发dataChanged而是用beginResetModel/endResetModel整体刷新或者用dataChanged一次性覆盖所有行emit dataChanged(index(0), index(m_messages.size() - 1), {TranslationRole});这样只发一次信号Qml端只刷新一次。6.4 自动保存与崩溃恢复编辑器类工具最怕的就是崩溃丢数据。我的做法是每次修改后启动一个定时器5秒无操作就自动保存到一个临时文件。下次打开时检测到临时文件存在提示用户是否恢复。void TsFileHandler::scheduleAutoSave() { m_autoSaveTimer.start(5000); } void TsFileHandler::onAutoSaveTimeout() { QString tempPath m_currentPath .autosave; saveToFile(tempPath); }这个功能实现起来不复杂但能极大提升用户体验。翻译人员往往一坐就是几个小时中间万一程序崩了没有自动保存的话损失就大了。7. 关于QXmlStreamReader/Writer的通用经验用了这么久QXmlStreamReader和QXmlStreamWriter有几个通用经验值得分享给刚接触这两个类的朋友。第一QXmlStreamReader是只读的QXmlStreamWriter是只写的它们不共享状态。你不能用同一个对象既读又写必须分别创建。而且QXmlStreamReader解析完一个文件后不能复用要解析新文件得重新构造。第二QXmlStreamReader的readNext()可能返回Invalid。当XML格式错误时readNext()会返回Invalid此时error()和errorString()能拿到具体错误信息。一定要在循环里检查reader.hasError()否则遇到坏文件会死循环。第三QXmlStreamWriter的writeCharacters()不能写空字符串。如果你传空字符串它什么都不写translation/translation会变成translation/。虽然语义上等价但Qt Linguist对自闭合的translation/和空的translation/translation处理可能不同建议统一用writeTextElement()或者手动写空字符串时加个判断。第四命名空间处理。如果你的XML带命名空间QXmlStreamReader的name()返回的是本地名namespaceUri()返回命名空间URI。.ts文件通常不带命名空间所以不用太担心但如果你要处理其他XML格式这个细节要注意。第五性能对比。我做过一个简单测试解析一个5000条翻译的.ts文件QXmlStreamReader流式解析大约需要50毫秒QDomDocument大约需要200毫秒正则表达式方案大约需要150毫秒但代码复杂度高得多。流式解析在内存占用上也有明显优势——QDomDocument需要把整个文档树加载到内存而QXmlStreamReader只需要维持当前元素的上下文。这个简易版Qt Linguist做下来最大的收获不是Qml界面怎么写而是对.ts文件格式和XML流式处理的理解深入了很多。很多细节是看文档看不出来的必须自己动手解析一遍、生成一遍遇到问题再排查才能真正掌握。如果你也在做类似的事情希望这些经验能帮你少走点弯路。
阅读完成 · 觉得有帮助?
咨询建站