我们项目中需要实现一个日志查看控件,这本是一个很简单的需求:写一个通用的控件,将字符串绑定到RichTextBox, 如果要查看日志,将日志赋值给字符串即可。这个控件很简单,在绝大多数情况下工作的都很好。但是最近经常有客户报告说日志打不开,或者打开后就无法响应了。检查后发现这些无法打开的日志都很巨大,文件长度大多都超过几千万行。显然不带任何优化的文本阅读器都撑不住这个级别的文本。通过观察及与客户的沟通,他们的典型的操作是鼠标上下滚动,翻阅前后内容。拖拽滚动条到某一个段落,然后在局部操作,如果文件很大,他们几乎不会全文阅读。客户希望操作的过程能够尽量不卡,或者少卡。
要实现这个需求并不容易。首先想到的就是不能一口气将所有字符都载入内存。只能一段一段的载入,同时这些段落又不能是离散的, 因为他们还需要上下滚动鼠标也能够平滑。通过比较简单的需求分析,我们发现最初的设计一个文本阅读器已经分裂成两个子任务了:1)文件操作 2)界面显示。
1)文件操作
我们需要实现一个类似于StreamReader的类,但是需要另外增加ReadNextLine(), ReadPreviousLine(), Locate() 方法。即读下一行的内容,读上一行的内容,定位到某一行。我们想到了以行为单位做索引。比如说,一个文件有一千万行,类初始化时,我们先将每行的偏移值算出,当要定位(Locate)到某一行时,直接通过索引找到这个偏移值,然后file seek。这个操作是瞬时的。但是前面的索引太耗时了。我做了一个实验,在我的笔记本上(lenovo T430s)计算一千万行文件的索引需要将近2分钟。而且如果所有的索引都保存在内存,需要将近400MB内存。这对于客户是不可以接受的。
建立索引这个思路应该是不错的,不过我们可以做进一步的优化。仔细研究一下一般使用习惯不难发现打开文件永远是从头开始的,然后鼠标上下滚滚查看临近的内容,偶尔需要跳转。这里,上下滚动鼠标是一个连续的动作,跳转是一个离散的动作。连续动作(ReadNextLine, ReadPreviousLine)显然用户不希望有卡的感觉,但是跳转(Locate),有一个停顿的过程应该是可以接受的。基于这个假设,我们又有了进一步的优化:类初始化时不需要建立全部的索引,只需要将文件的开始几行索引建立即可(在我们项目中先建立前100行)。如果ReadNextLine,就再建立后面连续多行的索引,ReadPreviousLine则建立前面多行的索引。这是基于局部原理:如果调用过一次,下一次也继续调用同样的操作的可能性也很大。通过这个方法能够确保连续的向前向后滚动鼠标不会有卡顿的现象。那如何做到Locate呢?比如说,在一个一千万行的文件,我们现在想跳转到第30万行,显然在计算偏移量之前我们需要确认这一行是否已经被索引过了?这样就要求存储索引的数据结构查询的时间复杂度尽可能的低,最好能够达到O(1),这里我们偷了下懒,直接使用Dictionary。如果索引没有建立好,我们就不得不重新建立。最理想的情况是,如果知道第299999行已经建立过索引了, 我们只需要再多计算一行即可。如果我们只使用Dictionary来存储索引,我们并不知道前面有哪几行已经建立过了,不得不从第一行开始扫描,这是很低效的。于是想到了用链表和字典来配合:链表用来保存索引的前后关系,字典用来随机检查索引是否已经建立。所以逻辑就是:
long FindOffset(int target) { long offset; if (IsIndexCreated(target, out offset) == true) { return offset; } else { int nearest = FindNearestIndex(target); GenerateIndex(nearest, target); return FindOffset(target); }
IsIndexCreated 即检查Dictionary是否包含这个index
FindNearestIndex 即检查LinkedList 找到离target最近的index结点
GenerateIndex 创建从nearest到target结点的所有索引
通过两个数据结构能够快速定位并更新,但是又有新的问题了:存索引的内存加倍了。存一千万个索引现在需要800MB内存。在极端情况下,比如说,打开文件后,直接跳转到第一千万行。这样GenereateIndex(1, 10000000)会很耗时:不仅要计算偏移量,还要插入Dictionary。 当数据量很大的时候,插入Dictionary的时间也是需要考虑的。所以为了减少内存使用,提高速度,GenereateIndex的过程我们不会将计算的所有偏移量都保存,仅仅从距离target最近的100个结点开始保存。这样效果非常显著。 这里还有一个地方是可以优化的:如果有人有这个耐心,从第一行连续的滚动鼠标直到最后一行(一千万行),那么真个文件的索引都将生成。基于这个我们可以使用LRU算法仅保留最近的10000个索引。
2)界面显示
粗看上去这个方案已经很完美了,但是我们回避了一个问题。由于初始化类的时候我们并没有创建整个文件的索引,我们并不知道这个文件到底有多少行。文件操作中,如果对于一个只有5千行的文件调用Locate(1000000),我们只索引到最大行数。这在界面显示就有问题了,因为滚动条在读取下一行时每次滚动多少是取决于最大行数的。我们的做法是在界面处理中提供一个CalcuateMaxiumLineNumber函数,这个函数可以设置一个timeout参数(例如100毫秒),如果最大行数能在timeout内算出,直接返回最大行数,如何算不出来,先返回一个一百万,然后启动一个线程在后台慢慢算,每隔一段时间(例如500毫秒)更新一下最大行数。这样就不会影响界面的打开了。