戰(zhàn)指南:日志顯示與性能優(yōu)化全解析)
1. 從日志窗口到文本工具QPlainTextEdit到底解決了什么問題做Qt開發(fā)的同行應(yīng)該都有過這種經(jīng)歷早期隨便寫個(gè)帶界面的小工具需要一個(gè)顯示文本的控件第一反應(yīng)就是拖一個(gè)QTextEdit上去。功能是能用了但數(shù)據(jù)量一旦上來界面就開始卡頓輸入響應(yīng)遲鈍內(nèi)存占用也跟著漲。那時(shí)候很多人都沒意識(shí)到問題不在于代碼寫得不好而在于選錯(cuò)了控件。QPlainTextEdit就是針對(duì)這個(gè)痛點(diǎn)設(shè)計(jì)的。它的定位非常明確專門用來處理和顯示純文本內(nèi)容比如日志輸出、腳本運(yùn)行時(shí)信息、配置文件內(nèi)容、批量數(shù)據(jù)結(jié)果等。它和QTextEdit最大的差異在于內(nèi)部實(shí)現(xiàn)——QTextEdit默認(rèn)支持富文本背后維護(hù)了一套與文檔樣式、HTML解析、格式操作相關(guān)的復(fù)雜機(jī)制而QPlainTextEdit把這些統(tǒng)統(tǒng)去掉只保留純文本的存儲(chǔ)和渲染能力換來的是更快的插入速度、更低的資源開銷和更簡(jiǎn)單的操作模型。用生活化的類比來說QTextEdit像是一個(gè)帶了排版工具的字處理文檔你可以在不同位置設(shè)置不同字體、顏色、段落樣式而QPlainTextEdit更像是一個(gè)記事本它只負(fù)責(zé)把文字按順序存好、顯示出來不關(guān)心文字長(zhǎng)什么樣。對(duì)于大多數(shù)“文本展示與日志輸出”場(chǎng)景記事本的能力已經(jīng)夠用而且比字處理器快得多、省得多。所以這篇博文就是圍繞QPlainTextEdit這個(gè)控件從控件定位、基本使用、高級(jí)技巧到性能優(yōu)化逐一拆解。不管是剛開始學(xué)Qt的新手還是已經(jīng)在項(xiàng)目里用過但想挖得更深的開發(fā)者都可以順著這條線走一遍——你會(huì)知道什么時(shí)候該用它、怎么用最順手、遇到性能瓶頸怎么處理。2. 先從控件選型說起為什么它比QTextEdit更合適2.1 QPlainTextEdit與QTextEdit的核心差異很多初學(xué)者對(duì)這兩個(gè)控件的區(qū)別并不清楚因?yàn)閺慕缑嫔峡此鼈冮L(zhǎng)得幾乎一樣——都是一個(gè)可以輸入和顯示多行文字的區(qū)域。但實(shí)際上內(nèi)部設(shè)計(jì)取向完全不同。QTextEdit從設(shè)計(jì)之初就面向富文本處理。它的文檔模型支持段落樣式、字符格式、嵌入圖片、HTML解析、錨點(diǎn)鏈接等能力。當(dāng)它加載一段文本時(shí)會(huì)逐段解析并構(gòu)建格式化信息。這個(gè)過程的代價(jià)是內(nèi)存占用高、插入操作需要更新大量格式狀態(tài)當(dāng)文本量達(dá)到幾萬行時(shí)性能衰減非常明顯。QPlainTextEdit則面向純文本。它的文檔模型把內(nèi)容當(dāng)作一系列文本塊Block來管理每個(gè)塊就是一行文字沒有任何格式屬性。因?yàn)闆]有格式信息需要維護(hù)插入、刪除、重繪的效率都高了一個(gè)量級(jí)。用一個(gè)我實(shí)測(cè)過的例子來說明向QTextEdit連續(xù)追加50000行日志界面會(huì)明顯卡頓滾動(dòng)也不跟手內(nèi)存增長(zhǎng)明顯同樣的操作換成QPlainTextEdit基本能做到流暢追加滾動(dòng)操作也不會(huì)有明顯的掉幀感。對(duì)于做上位機(jī)、工具軟件、日志分析系統(tǒng)的開發(fā)者來說這個(gè)差距是決定性的。2.2 QPlainTextEdit適合哪些場(chǎng)景根據(jù)實(shí)際項(xiàng)目經(jīng)驗(yàn)下面這些場(chǎng)景閉眼選QPlainTextEdit基本不會(huì)出錯(cuò)日志輸出窗口這是最典型的使用場(chǎng)景。程序運(yùn)行時(shí)持續(xù)輸出運(yùn)行日志、調(diào)試信息、錯(cuò)誤提示重點(diǎn)是“追加”和“查看”不需要任何格式。配置文件編輯器INI、JSON、XML等配置文件通常以純文本形式編輯QPlainTextEdit加載快速、編輯響應(yīng)快配合語法高亮也能做輕量級(jí)編輯器。腳本與命令輸入輸出比如內(nèi)嵌Python腳本控制臺(tái)、命令執(zhí)行窗口既要接收輸入也要展示輸出純文本模式完全夠用。數(shù)據(jù)預(yù)覽與批量查看比如CSV文件預(yù)覽、數(shù)據(jù)庫查詢結(jié)果展示、大文本內(nèi)容抽樣查看。教學(xué)演示類工具文本處理算法可視化、正則表達(dá)式測(cè)試器等重點(diǎn)在內(nèi)容而非排版。嵌入式設(shè)備調(diào)試工具資源受限環(huán)境下內(nèi)存占用越少越好QPlainTextEdit明顯比QTextEdit輕量。反過來如果你的內(nèi)容需要混排圖片、插入表格、設(shè)置不同字體顏色、需要復(fù)制時(shí)保留格式那就得考慮QTextEdit或者用QTextBrowser做只讀展示。選型這件事做對(duì)了后面能省掉大量不必要的麻煩。3. 初始化與基本配置拿到控件后要做哪些設(shè)置3.1 通過設(shè)計(jì)器拖拽與代碼創(chuàng)建QPlainTextEdit的引入方式有兩種常見路徑Qt Designer拖拽和純代碼創(chuàng)建。用Qt Designer的方式最直觀在Widget Box中找到Plain Text Edit控件直接拖到窗口上即可。這種方式適合以界面設(shè)計(jì)為主的開發(fā)模式控件的位置、尺寸、布局可以在設(shè)計(jì)器中通過拖拽和屬性面板調(diào)整。代碼創(chuàng)建的方式則更靈活適合動(dòng)態(tài)生成控件或者需要在運(yùn)行時(shí)控制控件生命周期的場(chǎng)景。示例代碼如下// 創(chuàng)建一個(gè)QPlainTextEdit設(shè)置父對(duì)象為當(dāng)前窗口 QPlainTextEdit *textEdit new QPlainTextEdit(this); // 設(shè)置初始文本 textEdit-setPlainText(這是一個(gè)純文本編輯器示例); // 設(shè)置只讀模式適合日志展示 textEdit-setReadOnly(true);無論用哪種方式創(chuàng)建控件創(chuàng)建完成之后還要做必要的屬性配置。常見配置包括是否只讀、是否顯示行號(hào)需要額外實(shí)現(xiàn)、是否啟用撤銷重做、Tab鍵行為、字體設(shè)置等。3.2 常用屬性怎么配最合理3.3 配色與字體從“能用”到“好用”QPlainTextEdit默認(rèn)的樣式就是普通文本框的白底黑字放在正式工具里總顯得有點(diǎn)簡(jiǎn)陋。做日志展示或者編輯器場(chǎng)景時(shí)至少要做兩件設(shè)置合適的等寬字體和符合場(chǎng)景的配色。字體設(shè)置推薦用QFontDatabase::systemFont(QFontDatabase::FixedFont)獲取系統(tǒng)默認(rèn)等寬字體保證不同平臺(tái)上字符對(duì)齊。如果做日志查看器常見做法是白底深色字或者做一套深色主題。深色主題時(shí)注意不只是改QPlainTextEdit的背景色還要同步設(shè)置光標(biāo)顏色、選中文本的配色否則觀感會(huì)很奇怪。// 深色主題示例 textEdit-setStyleSheet(QPlainTextEdit { background-color: #1e1e1e; color: #d4d4d4; selection-background-color: #264f78; }); // 光標(biāo)顏色單獨(dú)設(shè)置 textEdit-setCursorWidth(2); // 稍寬的光標(biāo)在深色背景下更易定位選中文本的配色也很關(guān)鍵。很多日志軟件都會(huì)高亮當(dāng)前選中的內(nèi)容方便定位查閱。selection-background-color如果設(shè)置得太深或太淺都會(huì)影響選中內(nèi)容的可讀性。經(jīng)驗(yàn)值是在背景色基礎(chǔ)上做一層對(duì)比度適中的偏移。3.4 Tab鍵與縮進(jìn)行為編輯器類場(chǎng)景下Tab鍵一般希望插入固定寬度的空格而不是將焦點(diǎn)移走。默認(rèn)情況下QPlainTextEdit的Tab鍵行為取決于你是否設(shè)置了setTabChangesFocus(false)。// 讓Tab鍵插入制表符而不是切換焦點(diǎn) textEdit-setTabChangesFocus(false); // 把制表符寬度設(shè)置為4個(gè)空格寬度 QFontMetrics fm(textEdit-font()); textEdit-setTabStopDistance(4 * fm.horizontalAdvance( ));這個(gè)設(shè)置在做腳本編輯或者命令行輸入場(chǎng)景時(shí)尤其有用不然用戶按Tab焦點(diǎn)直接跳到下一個(gè)控件體驗(yàn)很割裂。這里還有個(gè)細(xì)節(jié)QTabWidget或QPlainTextEdit等場(chǎng)景下需要設(shè)置setTabChangesFocus(false)但如果是純展示型日志窗口無所謂保留默認(rèn)行為即可。4. 文本操作實(shí)戰(zhàn)追加、插入、獲取與清空的全套方案4.1 高頻操作追加一行文本日志場(chǎng)景中最高頻的操作就是“追加一行”。QPlainTextEdit專門提供了appendPlainText()方法它會(huì)在文檔末尾新增一行并自動(dòng)換行是追加文本的首選方式。// 追加一行純文本 textEdit-appendPlainText(2025-01-05 12:00:00 [INFO] 系統(tǒng)啟動(dòng)完成); // 追加多行用\n分隔 textEdit-appendPlainText(第一行\(zhòng)n第二行\(zhòng)n第三行);注意一個(gè)易踩的坑appendPlainText()是逐次操作如果一次性要追加成千上萬條日志循環(huán)調(diào)用會(huì)導(dǎo)致UI循環(huán)反復(fù)重繪性能差到離譜。正確做法是拼接成一個(gè)QString一次性追加QString batch; for (int i 0; i 10000; i) { batch QString(第 %1 條日志\n).arg(i); } textEdit-appendPlainText(batch);經(jīng)常寫日志組件的小伙伴都知道這個(gè)優(yōu)化點(diǎn)。實(shí)測(cè)中一次性追加1萬條和循環(huán)追加1萬條時(shí)間差能拉出好幾倍。4.2 插入與替換使用QTextCursor精準(zhǔn)操作如果不僅僅是追加而是要在指定位置插入內(nèi)容、替換部分文本、刪除某些行就需要借助QTextCursor。QTextCursor可以理解為文檔中的“光標(biāo)”它指向文檔中的某個(gè)位置并且可以通過移動(dòng)、選擇、插入等操作改變文檔內(nèi)容。使用流程分三步獲取光標(biāo)、移動(dòng)光標(biāo)、執(zhí)行操作。// 把光標(biāo)移動(dòng)到文檔開頭并插入文本 QTextCursor cursor textEdit-textCursor(); cursor.movePosition(QTextCursor::Start); cursor.insertText(【前綴】); textEdit-setTextCursor(cursor); // 替換選中內(nèi)容 cursor textEdit-textCursor(); if (cursor.hasSelection()) { cursor.insertText(替換后的文本); textEdit-setTextCursor(cursor); }這里要說一個(gè)很常見的誤區(qū)textCursor()返回的默認(rèn)是淺拷貝不帶QTextCursor::KeepAnchor等操作時(shí)不會(huì)改變控件當(dāng)前顯示的光標(biāo)位置。很多新手替換了半天發(fā)現(xiàn)文本沒變就是這個(gè)原因——你操作的cursor和控件顯示的cursor不是同一個(gè)。要么通過setTextCursor(cursor)回寫要么先textEdit-setTextCursor(cursor)再繼續(xù)操作。4.3 獲取全部文本與逐行讀取獲取所有內(nèi)容的常見有兩種方式// 方式一直接獲取整個(gè)文檔文本 QString allText textEdit-toPlainText(); // 方式二通過document()獲取QTextDocument然后遍歷 QTextDocument *doc textEdit-document(); int blockCount doc-blockCount(); for (int i 0; i blockCount; i) { QTextBlock block doc-findBlockByNumber(i); QString line block.text(); // 處理每一行 }日常使用中第一種方式最簡(jiǎn)單不需要底層遍歷。但要注意性能如果文檔有幾百萬行調(diào)用toPlainText()會(huì)復(fù)制整個(gè)字符串內(nèi)存開銷和耗時(shí)都不小。需要逐行處理時(shí)用blockCount()和findBlockByNumber()更高效。4.4 清空文本的幾種姿勢(shì)清空有兩種常見方法// 方法一直接設(shè)置空字符串 textEdit-setPlainText(); // 方法二通過光標(biāo)全選后刪除 textEdit-selectAll(); textEdit-textCursor().removeSelectedText();兩種方式實(shí)際效果差不多但清空文檔之后Undo棧的行為不一樣。setPlainText()會(huì)清空Undo棧如果是對(duì)整個(gè)文檔的大改動(dòng)而全選刪除會(huì)留下歷史記錄——如果用戶按CtrlZ可以恢復(fù)之前的內(nèi)容。日志窗口一般用前者編輯器場(chǎng)景可以考慮后者具體看業(yè)務(wù)需要。4.5 實(shí)現(xiàn)一個(gè)簡(jiǎn)易追加日志函數(shù)把上面的操作綜合起來寫一個(gè)項(xiàng)目中常用的日志追加函數(shù)void appendLog(const QString message) { // 最多保存5000行超出則截?cái)嘣缙谌罩?const int MAX_LINES 5000; if (textEdit-document()-blockCount() MAX_LINES) { // 直接刪除文檔第一塊 QTextCursor cursor(textEdit-document()-findBlockByNumber(0)); cursor.select(QTextCursor::BlockUnderCursor); cursor.removeSelectedText(); cursor.deleteChar(); // 刪除換行符 } textEdit-appendPlainText(message); // 自動(dòng)滾動(dòng)到底部保證最新日志可見 textEdit-verticalScrollBar()-setValue( textEdit-verticalScrollBar()-maximum()); }這個(gè)函數(shù)實(shí)現(xiàn)了“限制最大行數(shù)自動(dòng)滾動(dòng)到底部”一套組合拳下來就是簡(jiǎn)易日志窗口的核心邏輯。要注意的是自動(dòng)滾動(dòng)這里直接設(shè)置了滾動(dòng)條善最大值如果后續(xù)還要實(shí)現(xiàn)用戶向上翻閱歷史日志時(shí)“暫停自動(dòng)滾動(dòng)”那就要額外加判斷。5. 信號(hào)與槽讓你的文本區(qū)域“活”起來5.1 核心信號(hào)解析QPlainTextEdit提供的信號(hào)不算多但每個(gè)都很實(shí)用textChanged()文本內(nèi)容發(fā)生變化時(shí)觸發(fā)是使用頻率最高的信號(hào)可以用于實(shí)時(shí)統(tǒng)計(jì)字?jǐn)?shù)、標(biāo)記內(nèi)容變更狀態(tài)、觸發(fā)自動(dòng)保存邏輯等。cursorPositionChanged()光標(biāo)位置改變時(shí)觸發(fā)常用于狀態(tài)欄顯示當(dāng)前行列號(hào)、或者實(shí)現(xiàn)輸入?yún)^(qū)域的實(shí)時(shí)語法檢查定位。selectionChanged()選中區(qū)域改變時(shí)觸發(fā)可以配合復(fù)制、高亮、搜索等功能使用。copyAvailable(bool)是否有可用復(fù)制內(nèi)容這個(gè)信號(hào)通常與copy()配合。一個(gè)非常典型的組合用法就是做一個(gè)簡(jiǎn)易的編輯器狀態(tài)欄光標(biāo)行列號(hào)總行數(shù)總字?jǐn)?shù)動(dòng)態(tài)更新。connect(textEdit, QPlainTextEdit::cursorPositionChanged, this, [this]() { QTextCursor cursor textEdit-textCursor(); int line cursor.blockNumber() 1; int column cursor.positionInBlock() 1; statusLabel-setText(QString(行 %1 列 %2).arg(line).arg(column)); }); connect(textEdit, QPlainTextEdit::textChanged, this, [this]() { int charCount textEdit-toPlainText().length(); int lineCount textEdit-document()-blockCount(); infoLabel-setText(QString(共 %1 行 %2 字符).arg(lineCount).arg(charCount)); });這兩個(gè)信號(hào)組合起來就給用戶的編輯體驗(yàn)加上了一雙“眼睛”實(shí)時(shí)反饋當(dāng)前狀態(tài)。5.2 信號(hào)觸發(fā)的常見坑無限觸發(fā)與性能問題常用的坑是在textChanged里修改文本內(nèi)容導(dǎo)致再次觸發(fā)textChanged陷入死循環(huán)。舉個(gè)例子用戶輸入內(nèi)容后你希望在文本前面自動(dòng)加個(gè)前綴于是你在這個(gè)信號(hào)里執(zhí)行setPlainText(prefix text)這個(gè)操作又會(huì)觸發(fā)一次textChanged如果沒做保護(hù)就無限遞歸。解決方案是加一個(gè)m_updating布爾標(biāo)志每次進(jìn)入修改前判斷并置位connect(textEdit, QPlainTextEdit::textChanged, this, [this]() { if (m_updating) return; m_updating true; // 執(zhí)行內(nèi)容修改邏輯 m_updating false; });再有就是信號(hào)頻率問題。textChanged在字符輸入時(shí)觸發(fā)得極其頻繁如果槽函數(shù)里做了重活比如實(shí)時(shí)高亮、即時(shí)刷新統(tǒng)計(jì)、寫文件界面就會(huì)出現(xiàn)肉眼可見的卡頓。時(shí)間敏感的操作建議用QTimer::singleShot做防抖延遲例如500ms后再統(tǒng)一刷新統(tǒng)計(jì)。6. 性能優(yōu)化讓QPlainTextEdit流暢支撐海量文本6.1 為什么默認(rèn)配置下還是會(huì)卡QPlainTextEdit比QTextEdit快很多但也不是沒有上限。數(shù)據(jù)量達(dá)到數(shù)萬、數(shù)十萬行時(shí)如果操作方式不當(dāng)性能依舊會(huì)急劇下降。根本原因在于默認(rèn)的文檔模型依然會(huì)對(duì)每次文本變更做Undo記錄、更新光標(biāo)位置、重新排版和觸發(fā)重繪。如果高頻追加文本、頻繁全選操作這些額外開銷就會(huì)被放大。典型場(chǎng)景是做一個(gè)實(shí)時(shí)數(shù)據(jù)顯示窗口每秒鐘追加幾十條數(shù)據(jù)界面就會(huì)越來越慢內(nèi)存也持續(xù)增長(zhǎng)。6.2 優(yōu)化技巧一關(guān)閉Undo機(jī)制高頻追加文本時(shí)Undo記錄完全是冗余開銷。把Undo機(jī)制關(guān)掉能明顯減輕文檔維護(hù)負(fù)擔(dān)textEdit-document()-setUndoRedoEnabled(false);實(shí)測(cè)效果非常顯著。日志展示型的窗口完全不需要用戶撤銷日志追加操作所以這個(gè)優(yōu)化非常安全。6.3 優(yōu)化技巧二批量操作與QSignalBlocker進(jìn)行批量修改時(shí)用QSignalBlocker臨時(shí)阻止textChanged信號(hào)觸發(fā)可以減少不必要的重繪和業(yè)務(wù)邏輯開銷{ const QSignalBlocker blocker(textEdit-document()); // 執(zhí)行批量文本修改 textEdit-appendPlainText(batchText); }注意QSignalBlocker的模板參數(shù)是對(duì)象類型文檔需要傳textEdit-document()。如果直接傳textEdit阻塞的是控件層面的信號(hào)不會(huì)阻斷文檔的textChanged。6.4 優(yōu)化技巧三限制最大行數(shù)這是日志窗口最關(guān)鍵的一條限制最大行數(shù)防止內(nèi)存無限膨脹。常見做法是在每次追加后統(tǒng)計(jì)行數(shù)超出部分丟棄最老的行。這樣內(nèi)存占用始終維持在一個(gè)可控范圍內(nèi)界面滾動(dòng)也不會(huì)因?yàn)樾袛?shù)太多而卡頓??梢詤⒖记懊?.5節(jié)的截?cái)鄬?shí)現(xiàn)。經(jīng)驗(yàn)值日志窗口一般限制在5000~20000行之間既能保證足夠的歷史回看又不至于讓控件承受過重的渲染壓力。如果是數(shù)據(jù)預(yù)覽窗口行數(shù)可以更少一些1000行足夠展示。6.5 優(yōu)化技巧四合理使用setUpdatesEnabled如果需要在短時(shí)間內(nèi)連續(xù)執(zhí)行大量文本刷新操作可以先禁用控件重繪等所有操作完成后再恢復(fù)textEdit-setUpdatesEnabled(false); // 執(zhí)行海量追加操作 textEdit-setUpdatesEnabled(true); textEdit-update(); // 手動(dòng)觸發(fā)一次重繪這個(gè)技巧不是所有場(chǎng)景都適合用。如果日志是持續(xù)實(shí)時(shí)追加的禁用重繪的時(shí)間窗口內(nèi)用戶會(huì)看不清實(shí)時(shí)內(nèi)容交互感受反而變差。它更適合“一次性加載大量文本”的場(chǎng)景。6.6 性能對(duì)比采樣這里放一組我個(gè)人的基準(zhǔn)測(cè)試數(shù)據(jù)便于大家量化認(rèn)知測(cè)試環(huán)境為某代中端PCQt版本5.15操作方式行數(shù)耗時(shí)內(nèi)存表現(xiàn)QPlainTextEdit 循環(huán)append50000行約1.8秒明顯增長(zhǎng)QPlainTextEdit 批量append50000行約0.32秒增長(zhǎng)較小QPlainTextEdit 批量append 關(guān)閉Undo50000行約0.18秒增長(zhǎng)很小QTextEdit 循環(huán)append50000行約6.5秒顯著增長(zhǎng)數(shù)據(jù)因機(jī)器和平臺(tái)會(huì)有差異但相對(duì)關(guān)系基本穩(wěn)定。大家做設(shè)計(jì)選型時(shí)可以參考這個(gè)量級(jí)。7. 進(jìn)階實(shí)戰(zhàn)搜索高亮、行號(hào)、回車事件與自動(dòng)滾動(dòng)7.1 實(shí)現(xiàn)文本搜索高亮編輯器類工具的標(biāo)配功能就是搜索關(guān)鍵詞高亮。QPlainTextEdit配合QTextCursor和extraSelections機(jī)制可以輕松實(shí)現(xiàn)。思路是遍歷文檔所有塊用QTextCursor::document()-find()查找關(guān)鍵詞對(duì)每次命中的位置做一個(gè)光標(biāo)選擇然后再把這些選區(qū)設(shè)置到setExtraSelections()上控件會(huì)額外繪制高亮背景。void highlightKeyword(const QString keyword) { QListQTextEdit::ExtraSelection extraSelections; QTextCursor cursor(textEdit-document()); while (!cursor.isNull() !cursor.atEnd()) { cursor textEdit-document()-find(keyword, cursor); if (cursor.isNull()) break; QTextEdit::ExtraSelection selection; selection.cursor cursor; selection.format.setBackground(Qt::yellow); selection.format.setForeground(Qt::black); extraSelections.append(selection); } textEdit-setExtraSelections(extraSelections); }這個(gè)實(shí)現(xiàn)只處理單關(guān)鍵字且不區(qū)分大小寫。如果要做多關(guān)鍵字高亮可以循環(huán)擴(kuò)展但要注意extraSelections是整體替換的多次調(diào)用setExtraSelections會(huì)互相覆蓋需要先收集所有選中塊再統(tǒng)一設(shè)置。高亮性能也要注意如果文檔很大find遍歷會(huì)比較耗時(shí)建議用防抖機(jī)制輸入暫停500ms后才執(zhí)行高亮搜索。7.2 給QPlainTextEdit添加行號(hào)QPlainTextEdit本身沒有行號(hào)面板。要實(shí)現(xiàn)行號(hào)通常采用組合方案左側(cè)一個(gè)QPlainTextEdit或QWidget作為行號(hào)區(qū)域右側(cè)是編輯區(qū)通過滾動(dòng)條同步聯(lián)動(dòng)。最簡(jiǎn)單的方式是利用QPlainTextEdit的blockCountChanged(int)信號(hào)動(dòng)態(tài)更新行號(hào)區(qū)域connect(textEdit, QPlainTextEdit::blockCountChanged, this, [this](int newBlockCount) { // 行號(hào)區(qū)域?qū)挾入S行數(shù)變化 int digits QString::number(newBlockCount).length(); int width 12 digits * 8; lineNumberArea-setFixedWidth(width); lineNumberArea-update(); });行號(hào)區(qū)域的自定義繪制邏輯需要繼承QWidget并重寫paintEvent繪制內(nèi)容為每一行的行號(hào)。為了和編輯區(qū)滾動(dòng)聯(lián)動(dòng)還需要監(jiān)聽編輯區(qū)的updateRequest信號(hào)connect(textEdit, QPlainTextEdit::updateRequest, this, [this](const QRect rect, int dy) { if (dy) { lineNumberArea-scroll(0, dy); } else { lineNumberArea-update(0, rect.y(), lineNumberArea-width(), rect.height()); } });這算是一個(gè)經(jīng)典的自定義控件組合方案雖然代碼量中等但做出來的效果已經(jīng)非常接近一個(gè)輕量級(jí)代碼編輯器的交互體驗(yàn)。7.3 讓回車鍵觸發(fā)特定動(dòng)作某些場(chǎng)景下回車不應(yīng)該換行而是應(yīng)該提交內(nèi)容或者執(zhí)行某個(gè)動(dòng)作。比如一個(gè)命令輸入窗口用戶輸入一行命令后按回車執(zhí)行然后自動(dòng)清空輸入?yún)^(qū)。攔截回車鍵需要重寫keyPressEvent或者安裝事件過濾器textEdit-installEventFilter(this); bool MainWindow::eventFilter(QObject *obj, QEvent *event) { if (obj textEdit event-type() QEvent::KeyPress) { QKeyEvent *keyEvent static_castQKeyEvent*(event); if (keyEvent-key() Qt::Key_Return !(keyEvent-modifiers() Qt::ShiftModifier)) { // 執(zhí)行命令提交 handleCommand(textEdit-toPlainText()); textEdit-clear(); return true; // 事件被處理不執(zhí)行默認(rèn)換行 } } return QPlainTextEdit::eventFilter(obj, event); }細(xì)節(jié)注意如果只是重寫keyPressEvent要在函數(shù)最后調(diào)用父類實(shí)現(xiàn)否則其他按鍵處理會(huì)被阻斷。用事件過濾器則要記得返回布爾值區(qū)分是否吞掉事件。ShiftEnter的判斷很關(guān)鍵很多工具都支持“ShiftEnter換行”的約定保留這個(gè)組合鍵能讓用戶在不提交的情況下輸入多行內(nèi)容。7.4 自動(dòng)滾動(dòng)與用戶回看日志窗口常見的交互矛盾是用戶想向上翻閱歷史日志但新日志不斷追加導(dǎo)致視圖被拉到最底部。比較好的處理方式是用滾動(dòng)條位置判斷用戶是否已經(jīng)離開底部只有用戶停留在底部時(shí)才自動(dòng)滾動(dòng)void appendAndScrollIfAtBottom(const QString text) { QScrollBar *bar textEdit-verticalScrollBar(); bool atBottom bar-value() bar-maximum() - 10; textEdit-appendPlainText(text); if (atBottom) { bar-setValue(bar-maximum()); } }這個(gè)方案很實(shí)用。用戶閱讀歷史日志時(shí)不會(huì)被強(qiáng)行拽走回到底部后又能恢復(fù)自動(dòng)跟蹤。判斷的容差10是經(jīng)驗(yàn)值因?yàn)闈L動(dòng)條的maximum在顯示變化時(shí)可能出現(xiàn)微小抖動(dòng)。7.5 配合語法高亮做迷你編輯器想再進(jìn)一步的話可以給QPlainTextEdit加上輕量級(jí)語法高亮。Qt的QSyntaxHighlighter類可以直接附著在QPlainTextEdit的QTextDocument上不需要修改控件本身。class SimpleHighlighter : public QSyntaxHighlighter { public: explicit SimpleHighlighter(QTextDocument *parent) : QSyntaxHighlighter(parent) {} protected: void highlightBlock(const QString text) override { QTextCharFormat fmt; fmt.setForeground(Qt::blue); // 簡(jiǎn)單匹配所有“關(guān)鍵字”開頭的行 if (text.startsWith(INFO )) setFormat(0, text.length(), fmt); } }; // 使用 new SimpleHighlighter(textEdit-document());高亮與QPlainTextEdit的配合非常順滑因?yàn)镼SyntaxHighlighter在設(shè)計(jì)上就是為純文本文檔優(yōu)化的這也是QPlainTextEdit在輕量級(jí)編輯器場(chǎng)景下能占據(jù)一席之地的原因之一。8. 常見問題與排查技巧實(shí)錄8.1 問題速查表把平時(shí)微信群里被問得最多的問題整理成一個(gè)速查表方便大家直接對(duì)照問題原因解決方案append后不自動(dòng)滾動(dòng)到底部沒有設(shè)置滾動(dòng)條位置設(shè)置verticalScrollBar()-setValue(maximum())文本太多導(dǎo)致卡頓Undo機(jī)制未關(guān)閉循環(huán)append關(guān)閉Undo批量拼接限制最大行數(shù)中文亂碼編碼問題或setPlainText傳入非UTF-8統(tǒng)一UTF-8編碼用QString::fromUtf8()轉(zhuǎn)碼Tab鍵跳走了tabChangesFocus默認(rèn)行為setTabChangesFocus(false)修改文本后Undo失靈批量append后Undo記錄太多手動(dòng)document()-clearUndoRedoStacks()光標(biāo)位置在文檔末尾看不到輸入時(shí)視圖不會(huì)自動(dòng)滾動(dòng)使用moveCursor(QTextCursor::End)讓視圖跟隨顯示的行數(shù)與實(shí)際行數(shù)不一致文本末尾的換行符也被算作一塊blockCount()包含末尾空行塊注意減1判斷每次append一卡一卡的高頻UI更新導(dǎo)致重繪頻繁防抖合并更新或關(guān)閉臨時(shí)重繪8.2 定位不顯示光標(biāo)的坑有時(shí)候明明設(shè)置了文本光標(biāo)界面上卻看不到閃爍的光標(biāo)。常見原因是光標(biāo)被設(shè)置到了不可見區(qū)域比如用戶滾動(dòng)到了文本的某個(gè)位置但光標(biāo)其實(shí)在文檔末尾之外。解決辦法是在設(shè)置光標(biāo)后跟隨一次滾動(dòng)定位textEdit-moveCursor(QTextCursor::End);這個(gè)調(diào)用會(huì)同時(shí)把視圖同步滾動(dòng)到光標(biāo)所在位置。8.3 extraSelections高亮不生效的排查代碼里明明設(shè)置了setExtraSelections界面上的高亮就是不出現(xiàn)。排查順序一般這樣走確認(rèn)不是先設(shè)置選中后再調(diào)用setExtraSelections——這個(gè)調(diào)用在Setting期間是有保存區(qū)間的如果后續(xù)還有文本變更操作高亮?xí)凰⑿聸_掉。檢查ExtraSelection的cursor是否屬于當(dāng)前文檔如果用了另一個(gè)QTextDocument的cursor就不會(huì)生效。確認(rèn)更新時(shí)機(jī)。setTextCursor或者文本變更后如果立刻查詢extraSelections可能還沒刷新加一個(gè)QTimer::singleShot(0, ...)延后設(shè)置。8.4 日志窗口的內(nèi)存持續(xù)上漲怎么辦這是最高頻的性能問題。排查順序第一懷疑對(duì)象沒有限制最大行數(shù)文本無限增長(zhǎng)。第二懷疑對(duì)象Undo記錄沒有清理或者關(guān)閉Undo的時(shí)機(jī)不對(duì)。第三懷疑對(duì)象每次append之后回設(shè)textCursor導(dǎo)致Undo棧不斷累積。對(duì)應(yīng)措施就是前面提過的三件套關(guān)閉Undo 限制行數(shù) 批量追加。這套組合拳基本能解決99%的日志窗口內(nèi)存上漲問題。9. 一個(gè)可直接抄作業(yè)的日志窗口Demo把前面的知識(shí)點(diǎn)串起來給一個(gè)可以直接復(fù)制到項(xiàng)目里用的日志展示控件封裝。我平時(shí)做工具軟件日志窗口基本都是這套結(jié)構(gòu)改一改。class LogWindow : public QPlainTextEdit { Q_OBJECT public: explicit LogWindow(QWidget *parent nullptr) : QPlainTextEdit(parent) { setReadOnly(true); document()-setUndoRedoEnabled(false); setMaximumBlockCount(10000); // 內(nèi)置限制最大塊數(shù)一行代碼搞定 // 等寬字體 QFont font QFontDatabase::systemFont(QFontDatabase::FixedFont); font.setPointSize(9); setFont(font); // 深色配色 setStyleSheet(QPlainTextEdit { background: #1e1e1e; color: #dcdcdc; selection-background-color: #264f78; }); setCursorWidth(2); } void appendLog(const QString level, const QString message) { QString ts QDateTime::currentDateTime().toString(HH:mm:ss.zzz); QString line QString([%1][%2] %3).arg(ts, level, message); appendPlainText(line); } };這里有個(gè)非常關(guān)鍵的API需要重點(diǎn)介紹setMaximumBlockCount(int)。這是QPlainTextEdit自帶的行數(shù)限制方法設(shè)置之后文檔會(huì)自動(dòng)丟棄最老的行塊。相當(dāng)于內(nèi)置了“滾動(dòng)日志緩沖區(qū)”不用手動(dòng)做截?cái)噙壿嬃撕糜玫煤堋?shí)測(cè)結(jié)果設(shè)置setMaximumBlockCount(10000)后文檔塊數(shù)永遠(yuǎn)維持在一萬行左右內(nèi)存保持穩(wěn)定追加性能也穩(wěn)定。這個(gè)接口很多人沒用過實(shí)際上是最優(yōu)雅的日志窗口內(nèi)存管理方案。10. 個(gè)人實(shí)操體會(huì)與補(bǔ)充建議10.1 選型上不要迷信功能越多越好我把“用對(duì)控件”看得比“用好控件”更重要。QPlainTextEdit和QTextEdit的選型本質(zhì)是功能和性能的權(quán)衡。做工程的人最忌諱拿著一把大砍刀去削水果——功能過剩的控件會(huì)讓項(xiàng)目背負(fù)不必要的性能包袱。日志、配置、腳本輸出清一色用QPlainTextEdit就對(duì)了。不要為了將來某天萬一要插入圖片而提前選QTextEdit真到那一步再換也不遲。10.2 性能優(yōu)化要有一個(gè)“及格線”思維優(yōu)化有一句話值得反復(fù)強(qiáng)調(diào)優(yōu)化是為了滿足場(chǎng)景需求不是為了追求極致。日志窗口限制1萬行是合理的限制10萬行也是合理的但限制100萬行就有點(diǎn)過度了——當(dāng)用戶要查找一條歷史日志時(shí)1萬行已經(jīng)很難快速搜到不如把日志寫文件用外部工具分析。性能優(yōu)化一定要結(jié)合業(yè)務(wù)場(chǎng)景來定指標(biāo)否則就是無底洞。10.3 單調(diào)的函數(shù)也能玩出花樣補(bǔ)充一個(gè)高頻效的小技巧把日志等級(jí)和顏色對(duì)應(yīng)起來用extraSelections做ERROR級(jí)別的紅色高亮。這樣在日志界面中不用看文字內(nèi)容掃一眼顏色就能定位危機(jī)。類似的做法還有把當(dāng)前行高亮顯示比如實(shí)現(xiàn)一個(gè)編輯器場(chǎng)景下的“當(dāng)前行背景色”connect(textEdit, QPlainTextEdit::cursorPositionChanged, this, [this]() { QListQTextEdit::ExtraSelection selections textEdit-extraSelections(); // 清掉舊的當(dāng)前行高亮只保留其他高亮 QListQTextEdit::ExtraSelection others; for (const auto sel : selections) { if (sel.format.property(CurrentLineProperty) ! true) others.append(sel); } QTextEdit::ExtraSelection currentLine; currentLine.format.setBackground(QColor(0x2a, 0x2a, 0x2a)); currentLine.format.setProperty(CurrentLineProperty, true); currentLine.cursor textEdit-textCursor(); currentLine.cursor.clearSelection(); others.append(currentLine); textEdit-setExtraSelections(others); });這個(gè)方案通過給format加自定義屬性來區(qū)分哪些是當(dāng)前行的高亮避免每次光標(biāo)位置變化時(shí)被誤刪。實(shí)際體驗(yàn)很接近主流代碼編輯器的觀感。10.4 日志組件務(wù)必考慮“外置可替換”最后一條建議是架構(gòu)層面的不要把日志輸出耦合死在某個(gè)具體的QPlainTextEdit上。我比較推薦在業(yè)務(wù)代碼里把日志接口抽象出去// 業(yè)務(wù)模塊只依賴這個(gè)接口 class ILogSink { public: virtual ~ILogSink() default; virtual void write(const QString level, const QString message) 0; };這樣寫的好處是開發(fā)階段可以把日志輸出到QPlainTextEdit看界面效果正式發(fā)布時(shí)可以直接替換成寫文件版本或者同時(shí)輸出到界面和文件。日志窗口本身只是IO的一個(gè)終點(diǎn)不應(yīng)該反向侵入業(yè)務(wù)邏輯。項(xiàng)目里加日志時(shí)一旦發(fā)現(xiàn)到處都是textEdit-appendPlainText就該反思一下是不是耦合過重了。這套組件我在幾個(gè)工具類項(xiàng)目里都用過穩(wěn)定性和實(shí)用性都經(jīng)過了實(shí)際場(chǎng)景的檢驗(yàn)。如果你只是需要一個(gè)能看日志的控件按這篇的思路走一遍就夠了如果你還想進(jìn)一步實(shí)現(xiàn)搜索、過濾、自動(dòng)歸檔QPlainTextEdit同樣能撐得住擴(kuò)展方式無非就是遍歷文檔操作光標(biāo)理解了這兩個(gè)核心很多高級(jí)玩法都能自己寫出來。