最近研究了下如何用qt的原生控件来加载和显示大文件(>1G),分享下一些摸索经验。
下文源码:
compilelife/loginsightgithub.com文件的内存映射
在开始qt部分之前,我们先了解一个概念——文件的内存映射。
我们知道一般读文件用到的API是fopen/fread/fclose
,或者是open/read/close
,这种方式都需要内核帮忙作一次拷贝。
linux中有一个函数叫mmap
(windows也有类似功能),可以避免这样的一次拷贝。
请看这幅对比图(图片来源:https://www.jianshu.com/p/eece39beee20):
当我们用fread/read
时,都是触发了一个步骤1的read
系统调用,然后内核帮忙到磁盘中把请求的文件内容读取到kernnel buffer,然后再copy回用户进程空间。
相比,如果用mmap
,一开始内核就把整个文件映射到了用户进程的虚拟内存中;映射过程只是分配了地址空间,并没有拷贝内存,所以速度快。这一段地址空间在代码层面看到的就是一块连续的内存,当代码访问这块内存,如果引发缺页异常,内核就会加载文件内存到buffer。这样就减少了一次内存拷贝。
使用mmap对于大文件的加载和显示有什么好处呢:
- 读取速度快
- 可以把整个文件当做代码中一个连续内存区域,直接以
const char*
访问,即可以透明地认为整个文件已经加载到进程内,且保存为一个字符串(指针)了。对于代码设计而言较方便。
mmap参考资料: https://www.jianshu.com/p/eece39beee20https://zhuanlan.zhihu.com/p/69555454
Qt里显示大文件
在Qt里,QFile::map
提供了跨平台的“文件内存映射”支持。所以通过调用QFile::map
就可以把文件“加载”为一个const char*
字符串使用。
我们知道,在 QPlainTextEdit
里,显示文本一般可以用setPlainText
。如果直接把map后的内存传递给setPlainText
会导致文件的所有内容被读入内存,这显然是不行的。
一般对大文件处理方式是“分页”,也就是一次只加载部分内容。
为了让用户感知不到文件被“分页”了,我们需要处理下,自动加载分页的内容。具体的做法:
- 监听滚动事件,自动加载下一个/上一个分页
- 隐藏滚动条,用外部滚动条替代;外部滚动条对应整个文件范围,并保持实时同步
思路
在开始实现前,我们最好有一个清晰的思路,可以建个简单的模型:
这里,我们把窗口可视区想象成一个固定高度的滑块,整个滑块可以在整个文件从头滑动到尾部——对应用户从第一行拉动滚动条(右侧灰色箭头)直到最后一行。
为了能减少滚动过程中频繁触发读取文件,可以设置一块预加载区域,比可见区域大。每次可见区域要滑出预加载区的时候,就触发一次预加载区的预读。
在实现上,预加载区域对应的就是setPlainText
加载的内容,而可见区域的滚动就直接由QPlainText
代为实现了。
于是,要实现大文件的加载和显示,只要: 1. 预读内容,通过setPlainText
到QPlainTextEdit
2. 处理QPlainTextEdit
的滚动事件,在即将滚出预读区的时候,更新预读区
当然,说起来容易,做起来还是要处理一些琐碎事务的。详见:https://github.com/compilelife/loginsight/blob/master/src/logtextedit.cpp
再谈文件的内存映射
当然,如果只是单纯地去显示一个大文件, 直接用常规的文件读写API也是可行的。map的优势还不够明显。
实际上,map在这个场景里,真正强大的地方是在于把文件当做“已经加载好的连续字符串”。在加载了大文件后,不可避免地需要做查找、定位等逻辑,这时使用map可同时优化效率和代码可读性。
比如,我们要在上面工作的基础上做全文搜索并定位到匹配行。这时QPlainText
的find
因为只能搜索预加载内容,无法使用。而基于map,只需要对map后的内存地址,执行strstr
按字符串查找,再把查找到的位置前后内容载入可视区即可。
总结
为了基于qt原生控件去高效地显示大文件,我们用了不少奇技淫巧,把QPlainTextEdit
伪装成了支持大文件的文本框。也许下一步可以试试看用QPlainTextDocumentLayout
实现自定义文本框,作更深入地优化。