Reqable项目日志:如何基于Flutter开发一个代码编辑器

3,547 阅读15分钟

Reqable正式发布以来,收到了很多开发者朋友们的肯定和支持。在此,写一个项目日志系列,记录整个项目过程中的问题和经验总结,这个系列以整个项目开发过程中非常具有挑战的一个模块开篇,即Reqable的代码编辑器的开发总结。

1. 需求

代码编辑器这个需求在整个项目立项前就已经作为头号难题准备攻克,为什么呢?

首先,作为一款对标Postman + Fiddler/Charles的API开发和调试工具,如果没有一个顺手的代码和文本编辑器组件,那么口碑必然要大打折扣。

其次,从功能上来讲,API开发过程中常用的JSON、XML等编辑的需求可以说是最基本的要求了,更何况还有Python脚本的编写,以及HTML、JS、CSS等数据格式的显示了。例如,下图JSON数据的编辑,可以说是API工具最基本的功能了。

screenshot_01.png

从我对相关竞品Postman等的调研情况来看,这一类基于Electron开发的应用,编码编辑器基本上都是选用的CodeMirror(草率了,应该是Monaco Editor),包括知名的VSCode也是基于其实现的代码编辑功能。JavaScript成熟的生态可以说是Electron得天独厚的优势了,但是计划基于Flutter开发的Reqable来讲就没那么幸运了。当然,选用Flutter有我自己的考虑,所以硬啃也要啃下这块硬骨头。

2. 功能拆解

在项目的预研阶段,我对整体的功能做了拆解,一款正常使用的代码编辑器大约分为下面几个组成部分:

  • 视图样式
  • 文字输入
  • 语法高亮
  • 代码提示和补全
  • 光标控制
  • 代码块折叠和展开
  • 搜索和替换
  • 快捷键功能
  • 事件处理

此外,我还定义了一些额外的要求,例如可以显示10M文本数据、10w行代码编辑不卡顿等。

2.1 视图样式

作为UI组件,我必须定义代码编辑器的样式。这里样式参考了常用的编辑器,例如VS Code、Android Studio等,最后决定采用最简单的样式:侧边栏 + 输入框。侧边栏用于显示行号和代码块状态,输入框作为内容显示区域,右上角顶部留着放搜索和替换UI组件。另外,侧边栏和输入框必须支持横行同时滚动,输入框还要支持横行滚动(非WordWrap模式)。

2.2 文字输入

顾名思义,这个是最核心的功能了。当然,我还要处理Tab缩进和取消缩进、闭合符号自动补全(例如括号引号等)、中文等语言的Composing等等。除了基本的键盘打字输入,还要处理快捷键输入,比如剪切、粘贴、撤销、重做等。此外,还要支持只读模式。

2.3 语法高亮

支持不同的语法高亮,包括JSON、XML、Python、Html、JS和CSS等等。由于Reqable计划支持亮色和暗色两种主题模式,所以高亮也需要支持多种配色方案。

2.4 代码提示和补全

主要是Reqable的Python脚本功能,根据Reqable提供的Python框架API进行代码提示和自动补全。遗憾的是由于开发时间超期,这个功能至今没有能实现。

2.5 光标控制

光标有两大基本功能,一是编辑器持有视图焦点的时候Flashing,二是光标位置控制,例如可以通过方向键控制光标移动,点击来控制光标出现在新的位置等。

2.6 代码块折叠和展开

对于比较大的JSON、XML等格式数据,折叠可以非常方便我们进行阅读或编辑。

2.7 搜索和替换

搜索支持匹配大小写和正则表达式,替换支持逐个替换和全部替换。

2.8 快捷键

实现各种快捷键功能,例如全选、复制、粘贴、剪切、撤回、重做、删除行、移动行、交换字符、搜索替换等等,最基本的快捷键都要有。

2.9 事件处理

主要是指鼠标(触摸板)的事件分为左键单击、右键单击、双击选词、滚动、长按滚动等等。

3. 预研

功能拆解出来后,这工作量我自己都吓了一跳,但也只能硬着头皮上了。由于刚开始接触Flutter,甫一开始,我便决定基于Flutter官方提供的TextField进行控件组装,觉得可以省去大量的开发时间。

很快,我便搞出一个小Demo,搞出一个有模有样的Widget,可以显示代码行号、支持滚动,其他的基本功能,例如文字输入、光标、快捷键和触摸事件等TextField本身都已经实现好了。还有些其他的功能,我以为再添添补补就可以了。

事实上,我错了,不然也就没有现在这一篇文章了,这是一个极其失败和草率的预研,为后面的推倒重做埋下了伏笔。

4. 推倒重来

在草率不充分的预研之后,我去忙项目的其他事情了,等开始接入代码编辑器的时候,大量的问题涌现出来。我在Mac平台上测试的时候,发现了以下问题。

4.1 排版问题
  • 一直按空格键不会换行#99139
  • 出界问题#98916
  • 测量文本高度以确定侧边行号位置,容易出现行号和代码不对齐的问题。
4.2 滚动问题
  • TextField只支持单向滚动,设置了横向滚动后就没有办法纵向滚动。
  • 由于一个ScrollController无法绑定到多个Widget,导致侧边栏无法和TextField同时滚动,虽然使用了Google官方的linked_scroll_controller,但是仍然可以看到侧边栏行号和代码文本滚动不同步的问题。
  • 长按选中文本然后拖动选中更多内容时,TextField的内容无法自动滚动。
  • 双击触发滚动问题 #106321
4.3 性能问题

TextField在显示较大文本时会有非常明显的卡顿,在编辑时更是卡得动弹不得,这是至今未解决的重大性能问题 #90063,主要原因是paragraph.layout()极度耗时。此外,TextField在使用TextInput.setEditingState与输入法模块(IME)通信时,来回传输大文本也导致严重的延时问题。

4.4 其他问题

此外,还有一些其他不好实现的难题,例如代码块点击展开、代码提示等功能,都非常需要在TextField的视图上绘制一些自定义元素。

最后,我决定放弃直接使用TextField的方案。经过仔细阅读TextField的源码,TextField中大量与代码编辑器无关的功能,也我也放弃了在其基础上改造的想法。所以,我决定自己基于SingleChildRenderObjectWidget来从头到脚实现一个全新的Widget,处理掉上面列出的各种问题。

5. 填坑之路

整个过程加上后面优化的时间差不多有2-3个月左右,说明这个工作量还是非常大的,这还是在有部分功能砍掉的基础上。下面,将整个细节为大家一一道来。

5.1 数据结构

Reqable代码编辑器内容的数据结构采用Segment树形结构,撤回和重做功能使用双向链表记录内容关键节点。使用树主要是方便处理代码块的折叠和展开;Segment主要是针对大文本的编辑,大部分编辑的内容变更范围都是在Segment内,只需要更新Segment的内容即可,不需要更新全局内容,再者与输入法模块(IME)通信时也只需要传输Segment的内容,大幅降低通信数据量。当然,对于小文本一个Segment也就够了。

所以,当打开一个文本时,首先需要按照换行符分割,然后组织Segment树形结构。当数据量过大时,必须使用Isolate做异步加载。

5.2 控制器

TextField的控制器TextEditingController不同,Reqable定义了一个CodeLineEditingController,但整体还是参照了TextField,比如都是继承的ValueNotifier,只不过Reqable使用的是自定义的CodeLineEditingValue。这样做的目的是尽量保证API的一致性,方便调用。

abstract class CodeLineEditingController extends ValueNotifier<CodeLineEditingValue> {

  factory CodeLineEditingController({
    CodeLines codeLines = _kInitialCodeLines,
    CodeLineOptions options = const CodeLineOptions(),
  }) => _CodeLineEditingControllerImpl(
    codeLines: codeLines,
    options: options
  );
  
}

由于Reqable编辑器数据结构最小单位是代码行,CodeLineEditingValue的定义和TextEditingValue也是有所不同:

class CodeLineEditingValue {

  final CodeLines codeLines;
  final CodeLineSelection selection;
  final TextRange composing;

  const CodeLineEditingValue({
    required this.codeLines,
    this.selection = const CodeLineSelection.zero(),
    this.composing = TextRange.empty,
  });
  
}
5.3 视图层级

虽然我决定整个编辑器包括侧边栏在内都是自主绘制,但是Widget树还是有点复杂,因为像焦点(Focus)、滚动(Scrollable)、事件监听(GestureDetector)、快捷键(Shortcuts)都是要使用Flutter框架提供的组件。这里空间太小,实在写不下。视图组合不是难点,难的是后面的视图绘制

5.4 视图绘制

这一步其实只需要处理两个地方:layoutpaint,写过Flutter自定义RenderObjectWidget的都清楚这两个步骤,不多讲。

前面说到Flutter的一个严重的性能问题就是paragraph.layout()导致的,但是这个函数我还是需要依赖的,除非我连底层的文字排版引擎也自己实现了,这是不切实际的。我的做法是,不需要去layout全部文档内容,只需要按照代码行去依次layout,直到可以填满整个Widget的显示区域,这就极大地减少了性能开销。

还必须考虑内容高度超过Widget的显示区域的情况,因为要设置Scrollable的滚动范围(直观的就是滚动条Scrollbar的长度)。在不layout全部文档内容的前提下,需要预估整个文档的内容高度。

同时,还需要对layout的结果进行缓存,在Widget尺寸以及文字style不变的情况下进行复用,最常见的使用情景就是上下滚动内容时不需要重复计算元素的尺寸。

设计起来简单,其实这个是整个编辑器逻辑最复杂的部分,尽管有注释但我现在来看也有点不懂了,总之反正是Work的,管他呢。

同样的,paint也是只需要绘制Widget的显示区域内的部分,这一点同上面的layout大同小异。

上面只讲了文本内容的部分,还有其他的元素需要绘制,例如一闪一闪的光标、代码行号、代码块折叠的标记、选中高亮区域、搜索匹配内容高亮区域、光标所在行标记等等。当其中某一部分需要重新绘制时,肯定不能重新绘制全部内容,所以这部分都是采用独立的RenderObject来处理,比如当光标一亮一灭,只需要重新绘制光标就可以了。

screenshot_02.png

5.5 语法高亮

接下来,我需要对显示的内容做一些美化,代码语法高亮是必然要实现的。

在flutter pub上面有一个非常不错的项目highlight.dart,我尝试直接接入到项目中,有了一个简单的效果,看起来还不错。但是仔细测试之后,发现了一些bug,这个项目又停止维护了。我认真学习了一遍highlight.dart源码,了解其是基于highlight.js使用dart翻译实现的,这里不得不再次感叹JavaScript的完善的生态。

仔细对比highlight.darthighlight.js两个仓库的源码,highlight.dart是基于highlight.js较早版本的实现,而且没有任何的单元测试。另外,highlight.dart并不是按照原样将JavaScript翻译成dart,这导致我在定位bug的过程中举步维艰。好吧,看来我只好自己造轮子了。

我clone了highlight.js仓库的最新版本代码(v11.5.1),完整按照这个项目的结构和定义,尽量保证原汁原味地将JavaScript翻译成dart,既方便我日后升级和维护,又方便我定位bug。幸运的是,dart和JavaScript的语法并没有太大的差异,比如都支持动态声明,这大大降低了我翻译的难度。一些小的语言方面的差异(比如JavaScript的类可以随时追加属性),也可以通过某些方式来克服。

截屏2023-06-20 16.52.30.png

前面这部分是规则解释器的实现,接下来就是语法规则的定义。在highlight.js里,语法规则(主要各种正则)都是基于JavaScript定义,这个是项目中最为复杂的部分,用dart来重新定义一遍每种语言的规则的话工作量巨大。这里要再次感谢highlight.dart这个项目,为我提供了一个非常棒的思路:解析JavaScript mode然后自动生成dart mode。

我参考highlight.dart使用gulp重新编写了规则解析逻辑,在这一步我发现了highlight.darthighlight.js各自存在的bug,顺手提交了一个PR 3547

最后,我还必须跑通highlight.js的所有测试用例来保证dart项目逻辑的正确性。终于,功夫不负有心人,我完整地用dart实现了highlight.js并跑通全部测试用例。

在接入代码编辑器中后,又遇到了新的问题。由于使用highlight.dart处理文本输出高亮结果是一个非常耗时的操作(大量的正则匹配运算),我不得不将其迁移到一个独立的Isolate Worker线程中,又得处理主线程UI绘制和异步线程运算结果的关系,比如当用户在修改代码之后Isolate Worker出运算结果之前,UI该绘制新代码的样式呢?这里就需要做一些即时的代码差异Diff,判断Diff差异点和highlight结果的关系,在Isolate Worker输出之前预测出新代码的高亮样式。

代码高亮也终于搞定了!下图是Reqable Python脚本的语法高亮效果:

screenshot_04.png

highlight.js翻译成dart语言后也并不是就高枕无忧了,不同语言运行时还有有些差异。例如dart为了性能考虑,stack设计地比较小,当对大文本进行语法解析的时候,正则表达式容易因回溯机制导致爆栈,详见这里

不管怎么样,highlight.js帮助我实现了代码编辑器的语法高亮功能,此外还被拓展应用到Flutter框架提供的TextField上面,例如Reqable的URL高亮效果也是通过它来实现的。

5.6 输入功能

前面已经实现了静态的代码显示逻辑,后面开始处理输入问题。区别于TextField使用的TextInputClient,我采用了DeltaTextInputClient。说来也巧,恰好Flutter在处理TextField的性能问题,刚刚提交了DeltaTextInputClient,准备用来替换TextInputClient,我就不客气地采用了。

DeltaTextInputClient正如类名所言,具备差异更新功能。TextInputClient在用户输入后会返回一个完整字符串,而DeltaTextInputClient会返回用户的输入行为,例如新增了几个字母,删除了几个字母,又或者是将A替换成了B,又或者是仅仅输入光标位置的变化。这真的是,我想睡觉Flutter送来了枕头。

我利用DeltaTextInputClient实现了智能输入,比如用户输入一个{,编辑器会自动补全后的},比如删除了一个",编辑器会自动删除另外的一半"。另外,也非常适合用来记录Redo/Undo的关键节点。

5.7 光标控制

主要是光标位置的确定,这个功能我本来以为非常地简单,事实证明是我想简单了,主要是有很多容易忽略的小细节。

比如上下箭头控制光标上下移动的时候,需要区分WordWrap和非WordWrap的情况,WordWrap的时候光标可能会在同一个代码行内上下移动,而且非WordWrap的时候光标肯定是在代码行间上下移动。

再比如说,当光标移动出到视图显示边界后,需要控制文档内容自动滚动一段距离保证光标一直显示在屏幕内。

还有由于不同字符所占的长度不同,光标移动的字符索引位置也不一样。比如emoji表情占据两个字符的长度,而ASCII和汉字等字符只占一个字符的长度。

5.6 代码折叠和展开

前面讲过,由于采用树形结构,非常容易控制代码的折叠和展开的逻辑。所以,稍微难处理的问题是如何判断代码可以折叠和展开。由于时间关系,我没有深入地去实现这个功能,只是简单地利用扫描法,搜索出可以闭合的[]{}代码块的位置。很明显,这对JSON非常得有效,但是对Python而讲就然并卵了。

5.7 搜索和替换

这个没什么太大问题。主要注意两点,一是当搜索结果出现在闭合的代码块内的时候,选中到此搜索结果的时候需要自动展开代码块,二是需要再开一个Isolate Worker来执行搜索运算。

5.8 其余细节

剩下的就是快捷键功能、事件处理、编写单元测试等大量细节完善工作了,这又占据了较大一部分的开发时间,等都差不多完成的时候已经两个月过去了。

6. 总结

我基本完成了一个简单的代码编辑器,虽然还缺少了重要的代码提示和补全功能,但是已经可以满足正常的使用。代码编辑器在Reqable中的效果:

screenshot_05.png

欢迎各位阅读!也欢迎大家来下载和体验 Reqable-先进HTTP生产力工具,也欢迎与我一起交流更多Flutter的开发经验和心得!

7. 补充

2024-02-05日更新。编辑器后续进行了来两次大的升级。

  • 手机端支持。
  • 代码提示和补全。

此外,已在Github开源:github.com/reqable/re-…