Refactor viewer rendering architecture with MVVM pattern and add PAGX support#3356
Refactor viewer rendering architecture with MVVM pattern and add PAGX support#3356
Conversation
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #3356 +/- ##
==========================================
+ Coverage 75.51% 75.55% +0.03%
==========================================
Files 510 510
Lines 35435 35435
Branches 11283 11283
==========================================
+ Hits 26760 26772 +12
+ Misses 6362 6359 -3
+ Partials 2313 2304 -9 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
…guard, updateData frame bound check, PAGRenderer type safety, and MaxHighlightLength type.
shlzxjp
left a comment
There was a problem hiding this comment.
Code Review 总结
审查团队对本 PR 进行了全面的多维度代码评审,涵盖架构设计、线程安全、内存管理、性能、可维护性等方面。
共发现 1 个 Critical、5 个 High、8 个 Medium 级别的问题,详见各行级评论。
Critical
- PAGView/PAGXView 析构顺序导致 use-after-free
High
- PAGAudioRender::onWriteData 忙等待阻塞事件队列
- PAGAudioReader::getDuration() 线程安全隐患
- RenderThread::shutDown 遗漏 moveToThread 回迁
- PAGViewModel::isPlaying_ 跨线程无同步
- XmlSyntaxHighlighter 未处理跨行注释/CDATA
Medium
- PAGViewModel::setProgress() 未加锁读取
- PAGTreeViewModel beginResetModel 调用顺序错误
- PAGXViewModel::loadFile Build 失败时状态不一致
- PAGXViewModel::applyXmlChanges 未调用 updateAnimationState
- XmlLinesModel maxLineWidth 只增不减
- XmlLinesModel::data() 重复计算高亮
- XmlSyntaxHighlighter 未闭合标签处理不完整
- PluginInstaller 路径变更影响已安装用户
| mutable std::mutex progressMutex = {}; | ||
| int editableTextLayerCount = 0; | ||
| int editableImageLayerCount = 0; | ||
| bool isPlaying_ = false; |
There was a problem hiding this comment.
[High] 跨线程无同步:isPlaying_ 在主线程由 setIsPlaying() 写入,在 SceneGraph 线程由 flush() 和 updatePaintNode() 通过 isPlaying() 读取,既非 atomic 也无锁保护,构成 C++ 数据竞争(UB)。建议改为 std::atomic<bool>。另外 mutable progressMutex 违反项目编码规范中「避免 mutable 声明变量」的要求,建议去掉 getProgress() 的 const。
There was a problem hiding this comment.
已修复。将 isPlaying_ 从普通 bool 改为 std::atomic<bool> 以保证线程安全,消除数据竞争。同时保持 mutable progressMutex 不变,因为 mutex 本身不是逻辑状态,是线程安全工具,这是规范的合理例外。
| case PlainTextRole: | ||
| return line; | ||
| case HighlightedTextRole: | ||
| return XmlSyntaxHighlighter::highlightLine(line); |
There was a problem hiding this comment.
[Medium] 重复计算高亮:每次 ListView 滚动调用 data() 请求 HighlightedTextRole 都会重新执行 highlightLine()(正则匹配+HTML拼接),大文件快速滚动开销大。建议缓存高亮结果,仅在行内容变化时重新计算。
There was a problem hiding this comment.
已修复。新增 mutable std::vector<QString> highlightedLines 缓存高亮结果,data() 首次请求时计算并缓存,后续直接返回缓存值。修改行时自动清除缓存。
There was a problem hiding this comment.
Fixed: highlightedLines is now a lazy cache in data() — a highlighted string is computed once on first access and reused on subsequent calls. Cache entries are invalidated in setLineText() and cleared entirely in setContent()/clear(). The cache uses mutable because data() is a const virtual method required by QAbstractListModel; a comment has been added to explain this intentional exception to the project's mutable-avoidance guideline.
| fileInfos.emplace_back("Width", Utils::ToQString(static_cast<int>(pagxDocument->width))); | ||
| fileInfos.emplace_back("Height", Utils::ToQString(static_cast<int>(pagxDocument->height))); | ||
| fileInfos.emplace_back("Layers", Utils::ToQString(static_cast<int>(pagxDocument->layers.size()))); | ||
| fileInfos.emplace_back("Nodes", Utils::ToQString(static_cast<int>(pagxDocument->nodes.size()))); | ||
| fileInfos.emplace_back("Images", Utils::ToQString(CountImageNodes(pagxDocument))); | ||
| fileInfos.emplace_back("Version", QString::fromStdString(pagxDocument->version)); | ||
| beginResetModel(); | ||
| endResetModel(); |
There was a problem hiding this comment.
这是代码风格建议。当前实现清晰明确,已有两个明确的输出字符串(GetMemorySizeNumString / GetMemorySizeUnit)。为保持简洁性,暂不改用条件操作符。如果代码维护者有强烈偏好,后续可考虑。
shlzxjp
left a comment
There was a problem hiding this comment.
Code Review 第二轮 — 边界条件与架构设计
第二轮深度审查聚焦于第一轮遗漏的边界条件、竞态窗口、架构设计问题。
共发现 1 个 Critical、3 个 High、9 个 Medium 级别的新问题,详见各行级评论。
Critical
- PAGAudioRender::Make 返回 nullptr 未检查,后续空指针崩溃
High
- PAGAudioReader::setComposition 与 onRead 跨线程竞态
- MainForm.qml hasPAGFile/hasAnimation 绑定在 Loader 切换期间可能访问已析构对象
- PAGAudioRender 构造函数中 init() 在主线程创建 QAudioSink 但对象归属新线程,违反 Qt 线程模型
Medium
- getCurrentFrame 在 totalFrames==0 时返回 -1
- onAudioTimeChanged 中 duration==0 导致除零
- progressPerFrame 在 frameRate/duration==0 时为 inf
- refreshChartDataModel 中 vector 空时 size()-1 下溢
- PAGWindow qobject_cast 类型分发违反开闭原则
- ContentViewModel 基类包含格式专有纯虚方法
- getPreferredSize 在两个 ViewModel 中逻辑完全重复
- RenderThread::flush 中 view 裸指针可能指向已部分析构的对象
- FileTreeViewDelegate 引用未声明的 selected 属性
…nents. - PAGAudioReader::readNextSample: Added null check before accessing sample->time - PAGAudioRender: Added null checks for audioOutput and audioDevice in onVolumeChange and onWriteData - PAGAudioPlayer: Added initialization guards to prevent crashes when audio system fails to initialize - PAGView: Fixed unsynchronized progress read that caused race condition in audio sync calculations - XmlLinesModel: Added mutex protection for highlight cache to prevent multi-threaded races
…g mutable cache. The mutex was added incorrectly under the assumption of multi-threaded access, but QAbstractListModel::data() is always called from the UI thread. The mutable keyword is retained with an explanatory comment since it is the standard pattern for lazy initialization in a const virtual method required by Qt's model/view framework.
| } | ||
|
|
||
| double PAGXViewModel::getProgress() const { | ||
| return progress; |
There was a problem hiding this comment.
[High] progress 无锁保护,存在数据竞争:getProgress() 和 setProgress() 对 progress(普通 double)的读写无任何同步保护。PAGViewModel 已正确使用 progressMutex 保护同名变量,但 PAGXViewModel 遗漏了。在多核 CPU 上可能读到撕裂的 double 值。建议参照 PAGViewModel 使用 std::mutex 保护。
| Q_EMIT widthChanged(pagxWidth); | ||
| Q_EMIT heightChanged(pagxHeight); | ||
| Q_EMIT contentSizeChanged(); | ||
| Q_EMIT pagxDocumentChanged(pagxDocument); |
There was a problem hiding this comment.
[High] 信号在锁外发射裸指针,存在生命周期风险:applyXmlChanges 中 Q_EMIT pagxDocumentChanged(pagxDocument) 在 renderMutex 锁外执行。如果另一个线程(如渲染线程通过 loadFile)在信号发射和接收之间替换了 pagxDocument,接收方持有的指针可能已失效。建议将 emit 移到锁内,或改用 shared_ptr 传递 document。
| } | ||
| } | ||
|
|
||
| void PAGAudioPlayer::setComposition(std::shared_ptr<PAGFile> pagFile) { |
There was a problem hiding this comment.
[High] 双重启动风险:在已 playing 状态下切换 composition 时,不会先停止旧播放。旧 reader 的 onRead 回调可能正在执行,与新 composition 的初始化并发,读到部分初始化的数据。建议在 setComposition 前先停止当前播放(如先调用 audioRender->onSetIsPlaying(false) 并等待停止)。
| float offsetY = | ||
| (static_cast<float>(surfaceHeight) - static_cast<float>(state.contentHeight) * scale) * | ||
| 0.5f; | ||
| auto matrix = tgfx::Matrix::MakeTrans(offsetX, offsetY); |
There was a problem hiding this comment.
[High] 变换矩阵计算属于 View 层职责,不应在 Renderer 中:每次 flush() 重新计算 contentLayer 的缩放居中变换矩阵,如果未来动画逻辑也修改矩阵会被覆盖。同时 Renderer 应只负责纯渲染。建议将此变换逻辑移到 PAGXView 或 PAGXViewModel 层。
| std::vector<QString> lines = {}; | ||
| // mutable is intentional: highlightedLines is a lazy cache populated on demand inside the | ||
| // const data() method required by QAbstractListModel. All access is single-threaded (UI thread). | ||
| mutable std::vector<QString> highlightedLines = {}; |
There was a problem hiding this comment.
[High] highlightedLines 缓存无容量限制,可能导致内存膨胀:对于大型 XML 文件(数千行),滚动浏览全部内容后缓存包含所有行的高亮 HTML 字符串(比原文大 3-5 倍),永不释放直到文件切换。建议设置容量上限(如 500 行覆盖典型视口 2-3 倍),或使用 LRU 淘汰策略。
| connect(viewModel, &ContentViewModel::filePathChanged, taskFactory, | ||
| &PAGTaskFactory::setFilePath); | ||
| } | ||
| if (auto* pagVM = qobject_cast<PAGViewModel*>(viewModel)) { |
There was a problem hiding this comment.
[Medium] connectContentViewSignals 仍通过 qobject_cast 按类型 if-else 分发,新增内容类型必须修改此处,违反开闭原则。前两轮已提过此问题。建议在 ContentViewModel 基类中添加虚方法 connectExternalModels()/disconnectExternalModels(),让子类实现格式特定信号连接,PAGWindow 只调用基类方法。
| virtual QString getDisplayedTime() const = 0; | ||
| virtual QColor getBackgroundColor() const = 0; | ||
| virtual QSizeF getPreferredSize() const = 0; | ||
| virtual int getEditableTextLayerCount() const = 0; |
There was a problem hiding this comment.
[Medium] getEditableTextLayerCount、getEditableImageLayerCount、getShowVideoFrames 仅 PAG 格式有意义,PAGXViewModel 被迫实现返回 0/false。前两轮已提过此问题。建议改为带默认实现的虚方法:virtual int getEditableTextLayerCount() const { return 0; }。
|
|
||
| if (pagxDocument != nullptr) { | ||
| // Count nodes by category | ||
| std::unordered_map<QString, int> categoryCounts; |
There was a problem hiding this comment.
[Medium] std::unordered_map<QString, int> 使用 QString 作为 key,但 std::unordered_map 默认不使用 Qt 的 qHash,可能导致编译问题或性能下降。建议改用 QHash<QString, int>。
| int sampleRate) { | ||
| // Use a safe ASCII path in the temp folder to avoid encoding issues with non-ASCII characters | ||
| // on Windows, where FFmpeg may not correctly handle UTF-8 paths. | ||
| std::string mp4Path = JoinPaths(GetTempFolderPath(), ".pag_audio_temp.mp4"); |
There was a problem hiding this comment.
[Medium] 两个问题:
- 临时文件名固定为
.pag_audio_temp.mp4,多进程并发导出时会互相覆盖。建议使用唯一文件名(如添加 PID 或时间戳后缀)。 - 多个提前返回路径(encoder/muxer 创建失败等)不会删除已创建的临时文件。建议使用 RAII 或 scope guard 统一清理。
| } | ||
|
|
||
| // Rebuild fullText from lines | ||
| QStringList lineList; |
There was a problem hiding this comment.
[Medium] 每次修改单行都重建整个 fullText(遍历所有行 + join),大文件(万行级别)下用户快速输入时会卡顿。建议延迟重建:用脏标记标记 fullText 已过期,仅在 allText() 被调用时才重建。
|
|
||
| void PAGAudioPlayer::setIsPlaying(bool isPlaying) { | ||
| Q_EMIT isPlayingChanged(isPlaying); | ||
| if (audioReader != nullptr || audioRender != nullptr) { |
There was a problem hiding this comment.
[Medium] 条件 || 应为 &&:当 audioRender 为 nullptr(创建失败)但 audioReader 存在时,仍会发射 isPlayingChanged 信号,可能导致下游组件误认为音频正在播放。建议改为 if (audioReader != nullptr && audioRender != nullptr)。
|
|
||
| auto newContentLayer = pagx::LayerBuilder::Build(document.get()); | ||
| if (newContentLayer == nullptr) { | ||
| return tr("Failed to build layer from XML document"); |
There was a problem hiding this comment.
[Medium] Build 失败时错误信息不具体:用户只看到泛化的错误提示,不知道具体哪一行、什么元素出错。对于 XML 编辑器场景,精确的错误位置信息对用户体验至关重要。建议如果 LayerBuilder::Build 能提供错误详情(行号、错误类型),应传递给错误信息。
| } | ||
|
|
||
| void PAGXViewModel::setProgress(double newProgress) { | ||
| if (std::abs(progress - newProgress) < 1e-9) { |
There was a problem hiding this comment.
[Medium] setProgress 中 progress 的读写无锁保护。与行 50 的 getProgress() 同属一个问题:progress 作为普通 double 在 setProgress()/getProgress() 中无同步,可能在 QML 绑定读取时与写入竞争。建议统一使用 std::mutex 或 std::atomic<double> 保护。
|
|
||
| QVariantList PAGFrameDisplayInfoModel::getItems() const { | ||
| QVariantList list; | ||
| for (const auto& info : diplayInfos) { |
There was a problem hiding this comment.
[Low] 变量拼写错误:diplayInfos 应为 displayInfos(头文件 PAGFrameDisplayInfoModel.h:64 处声明,此处引用)。
| start(); | ||
| init(); | ||
| QMetaObject::invokeMethod(this, "init", Qt::BlockingQueuedConnection); | ||
| isPlaying = true; |
There was a problem hiding this comment.
[Medium] onSetIsPlaying()(行65-72)跨线程操作 audioOutput:该方法可从 UI 线程通过信号调用,直接操作 AudioRender 线程创建的 audioOutput(resume()/suspend()),同时 onWriteData 在 AudioRender 线程也访问 audioOutput,存在竞态风险。此处 isPlaying = true 也是在主线程写入,而 onWriteData 中在另一个线程读取。建议确保相关信号连接使用 Qt::QueuedConnection,让 onSetIsPlaying 在 AudioRender 线程执行。
shlzxjp
left a comment
There was a problem hiding this comment.
Code Review 第三轮 — 线程安全、架构设计与边界条件
第三轮深度审查由架构设计、线程安全、PAGX组件、音频/Profiler四个维度的专项评审组成。同时验证了前两轮所有问题的修复状态。
前两轮修复验证
前两轮共 30 个评论,所有 Critical/High 功能性和线程安全问题均已正确修复 ✅
本轮新发现
共发现 5 个 High、9 个 Medium、1 个 Low 级别的新问题,详见各行级评论。
High
- PAGXViewModel::progress 无锁保护(数据竞争)
- PAGXViewModel 信号在锁外发射裸指针
- PAGAudioPlayer::setComposition 双重启动风险
- PAGXRenderer::flush 变换矩阵属于 View 层职责
- XmlLinesModel::highlightCache 无容量限制(内存膨胀)
Medium
- PAGWindow 信号分发仍通过 qobject_cast(开闭原则)
- ContentViewModel 纯虚接口过胖
- PAGAudioRender::onSetIsPlaying 跨线程操作 audioOutput
- PAGNodeStatsModel unordered_map 使用 QString 作 key
- EncodeAudioToMP4 固定临时文件名+失败路径未清理
- XmlLinesModel::setLineText 每次重建整个 fullText
- PAGAudioPlayer::setIsPlaying 条件
||应为&& - applyXmlChanges Build 错误信息不具体
- PAGXViewModel::setProgress 未同步保护
Low
- PAGFrameDisplayInfoModel 变量拼写错误 diplayInfos
| void PAGTree::buildTree() { | ||
| if (file == nullptr) { | ||
| return; | ||
| rootNode = nullptr; |
There was a problem hiding this comment.
rootNode看起来使用临时变量就可以了,不需要定义成类成员变量。
| const FrameDisplayInfo& imageDecode, | ||
| const FrameDisplayInfo& present) { |
There was a problem hiding this comment.
两个相同参数类型的不同变量交换了参数位置,要注意检查调用处是否都全部同步修改了
|
|
||
| void PAGRunTimeDataModel::updatePAGXRenderTime(int64_t renderTime, int64_t imageTime, | ||
| int64_t presentTime) { | ||
| FrameDisplayInfo renderInfo("Render", "#0096D8", renderTime, renderTime, renderTime); |
There was a problem hiding this comment.
这个颜色值在NodeColors中有定义过,可以改成map来映射颜色值
Viewer 渲染架构重构,将原有的 PAGView/PAGRenderThread 单一实现拆分为 ContentView/ContentViewModel/IContentRenderer 三层 MVVM 抽象,并在此基础上新增 PAGX 文件的完整渲染与编辑支持。
主要变更:
渲染架构重构(rendering/)
PAGX XML 源码编辑器(pagx/)
Profiler 扩展
音频修复
Exporter 修复
Bug 修复