qt如何实现大文件的加载和显示

最近研究了下如何用qt的原生控件来加载和显示大文件(>1G),分享下一些摸索经验。

下文源码:loginsight

文件的内存映射

在开始qt部分之前,我们先了解一个概念——文件的内存映射。

我们知道一般读文件用到的API是fopen/fread/fclose,或者是open/read/close,这种方式都需要内核帮忙作一次拷贝。

linux中有一个函数叫mmap(windows也有类似功能),可以避免这样的一次拷贝。

请看这幅对比图(图片来源:https://www.jianshu.com/p/eece39beee20):

qt如何实现大文件的加载和显示

当我们用fread/read时,都是触发了一个步骤1的read系统调用,然后内核帮忙到磁盘中把请求的文件内容读取到kernnel buffer,然后再copy回用户进程空间。

相比,如果用mmap,一开始内核就把整个文件映射到了用户进程的虚拟内存中;映射过程只是分配了地址空间,并没有拷贝内存,所以速度快。这一段地址空间在代码层面看到的就是一块连续的内存,当代码访问这块内存,如果引发缺页异常,内核就会加载文件内存到buffer。这样就减少了一次内存拷贝。

使用mmap对于大文件的加载和显示有什么好处呢:

  • 读取速度快
  • 可以把整个文件当做代码中一个连续内存区域,直接以const char*访问,即可以透明地认为整个文件已经加载到进程内,且保存为一个字符串(指针)了。对于代码设计而言较方便。

mmap参考资料:
https://www.jianshu.com/p/eece39beee20
https://zhuanlan.zhihu.com/p/69555454

Qt里显示大文件

在Qt里,QFile::map提供了跨平台的“文件内存映射”支持。所以通过调用QFile::map就可以把文件“加载”为一个const char*字符串使用。

我们知道,在 QPlainTextEdit里,显示文本一般可以用setPlainText。如果直接把map后的内存传递给setPlainText会导致文件的所有内容被读入内存,这显然是不行的。

一般对大文件处理方式是“分页”,也就是一次只加载部分内容。

为了让用户感知不到文件被“分页”了,我们需要处理下,自动加载分页的内容。具体的做法:

  1. 监听滚动事件,自动加载下一个/上一个分页
  2. 隐藏滚动条,用外部滚动条替代;外部滚动条对应整个文件范围,并保持实时同步

思路

在开始实现前,我们最好有一个清晰的思路,可以建个简单的模型:

qt如何实现大文件的加载和显示

这里,我们把窗口可视区想象成一个固定高度的滑块,整个滑块可以在整个文件从头滑动到尾部——对应用户从第一行拉动滚动条(右侧灰色箭头)直到最后一行。

为了能减少滚动过程中频繁触发读取文件,可以设置一块预加载区域,比可见区域大。每次可见区域要滑出预加载区的时候,就触发一次预加载区的预读。

在实现上,预加载区域对应的就是setPlainText加载的内容,而可见区域的滚动就直接由QPlainText代为实现了。

于是,要实现大文件的加载和显示,只要:

  1. 预读内容,通过setPlainTextQPlainTextEdit
  2. 处理QPlainTextEdit的滚动事件,在即将滚出预读区的时候,更新预读区

当然,说起来容易,做起来还是要处理一些琐碎事务的。详见:https://github.com/compilelife/loginsight/blob/master/src/logtextedit.cpp

再谈文件的内存映射

当然,如果只是单纯地去显示一个大文件, 直接用常规的文件读写API也是可行的。map的优势还不够明显。

实际上,map在这个场景里,真正强大的地方是在于把文件当做“已经加载好的连续字符串”。在加载了大文件后,不可避免地需要做查找、定位等逻辑,这时使用map可同时优化效率和代码可读性。

比如,我们要在上面工作的基础上做全文搜索并定位到匹配行。这时QPlainTextfind因为只能搜索预加载内容,无法使用。而基于map,只需要对map后的内存地址,执行strstr按字符串查找,再把查找到的位置前后内容载入可视区即可。

总结

为了基于qt原生控件去高效地显示大文件,我们用了不少奇技淫巧,把QPlainTextEdit伪装成了支持大文件的文本框。也许下一步可以试试看用QPlainTextDocumentLayout实现自定义文本框,作更深入地优化。