可可笔记
首发于可可笔记
实现简易的代码编辑器

实现简易的代码编辑器

最近在 iOS 平台做一个简单的代码编辑器,最后的效果如图:

这篇文章简单总结一下技术方案和一些考虑的点。

# 目标

一个可被接受的代码编辑器,需要具备下面几个基本条件:

  • 实时代码高亮
  • 自动折行
  • 自动保持缩进
  • 自动插入花括号
  • 方便输入符号

# 技术方案

实现代码高亮,通常有以下几种技术方案可选:

  • WebView contentEditable
  • CoreText
  • JavaScriptCore + TextKit

其中 WebView 的方案是最简单的,但是实践下来却发现体验和要求相去甚远,例如自动折行很难实现,要知道如果是在手持设备上左右滑动,是非常不好的体验。另一方面性能上也是差强人意,其他格式化方面的需求就更难做到。

如果是用 CoreText/TextKit 的话,理论上可以完美达到任何文本编辑器的效果,但是这个工作量太大了,即便是有一些 CoreText 的封装,要做到这样的效果还是难以接受,如果我们并不是以做一个代码编辑器为主要任务的话,实在是没有必要的一件事情。

所以最后选择了类似于这个项目的方案:JavaScriptCore + TextKit。

这个项目很聪明,他利用 JavaScriptCore 来解析一个 js 库(他用的 highlight.js),然后把生成的 HTML 转换成一个 NSAttributedString,再用这个 AttributedString 去设置 UITextView

由于是 UITextView 实现的,你直接拥有了自动折行的功能,不用在一行很长的时候左右移动了。

如果你只需要渲染一次的话,只需要转换的部分就够了,如果需要实时编辑,则需要通过 NSTextStorageprocessEditing 去动态的做这个事情。Highlightr 这个项目提供了非常好的思路,但我没有直接使用它,主要是他的代码太乱了,我选择了自己实现了一份。

# Highlighter 的核心逻辑

以通过 JavaScriptCore 调用 highlightJS 为例,主要要实现以下几个步骤:

  • 解析主题 css 文件,将 css 属性转换成内设置到 NSAttributeString 里面的配置
  • 加载 highlight.js,调用 highlight 方法将要渲染的代码转换成 HTML
  • 遍历每个 <span>,将 HTML 转换成 NSAttributedString
  • 实现 NSTextStorage 的 processEditing 让上述过程在编辑过程中保持实时

# 自动保持缩进

移动设备上的输入成本是很高的,所以保持缩进是很重要的一件事情。保持缩进的逻辑是这样的:当用户输入一个换行符的时候,要自动添加空格(我是 2 spaces 党,不是 tab 党),方法是通过实现这个代理:

- (BOOL)shouldChangeTextInRange:(NSRange)range replacementText:(NSString *)text {
    if ([text isEqualToString:@"\n"]) {
        [self reindent];
        return NO;
    }
    return YES;
}

检测到用户输入换行符的时候,做一次 reindent,reindent 的逻辑很简单,从 selectedRange 往前找空格数和 tab 数,直到碰到换行符,看起来大概是这样:

int spaceZero = bracket ? 2 : 0;
int spaces = spaceZero;
int tabZero = bracket ? 1 : 0;
int tabs = tabZero;

for (int i=location-1; i>=0; --i) {
    unichar ch = [self.text characterAtIndex:i];
    if (ch == ' ') { // space
        ++spaces;
    } else if (ch == '\t') {
        ++tabs;
    } else if (ch == '\n') { // return
        break;
    } else { // other
        spaces = spaceZero;
        tabs = tabZero;
    }
}

BOOL spaceBased = spaces >= 2;
NSMutableString *refill;

if (spaceBased) { // space based
    refill = [NSMutableString stringWithFormat:@"\n%@", [NSString stringWithSpaces:spaces]];
} else { // tab based
    refill = [NSMutableString stringWithFormat:@"\n%@", [NSString stringWithTabs:tabs]];
}

NSMutableString *text = self.text.mutableCopy;
[text insertString:refill atIndex:selectedRange.location];
self.text = text;
self.selectedRange = NSMakeRange(selectedRange.location + spaces + 1, 0);

这里还顺便照顾了一下 tab 党,虽然我极不情愿这样做。

# 自动插入花括号

iOS 键盘上面输入花括号对的成本很高,所以要支持自动输入花括号,具体表现是,当用户输入一个 { 的时候按回车,另外一个 } 要自动出现并且格式化,光标跑到正确的位置。这件事情还是在上面的 reindent 方法里面做,首先要找出换行前最后一个非空白字符是不是左花括号:

BOOL bracket = NO;
for (int i=location-1; i>=0; --i) {
    unichar ch = [self.text characterAtIndex:i];
    if (ch == '{') {
        bracket = YES;
        break;
    } else if (ch == ' ' || ch == '\t') {
        continue;
    } else {
        break;
    }
}

当 bracket 为 YES 的时候,说明需要进行括号配对,这个效果有很多种实现方案,这里讲一种比 Xcode 的效果好一点点的(Xcode 可能会出现括号无法匹配的情况),就是我们真的去做一次括号匹配算法,复杂度是 O(len),len 是文本长度,括号匹配算法是一个典型的栈应用,可以在网上随意找到:

- (BOOL)bracketsBlanced {

    NSMutableArray *stack = [NSMutableArray array];

    for (int i=0; i<self.length; ++i) {
        unichar ch = [self characterAtIndex:i];
        if (ch == '{') {
            [stack addObject:@(ch)];
        } else if (ch == '}') {
            if (stack.count == 0) { // error
                return NO;
            } else {
                unichar top = [stack.lastObject charValue];
                if (top == '{' && ch == '}') {
                    [stack removeLastObject];
                } else {
                    return NO;
                }
            }
        }
    }
    return stack.count == 0;
}

这里只考虑花括号的情况,当发现括号不平衡的时候,主动在上面插入右括号以保持平衡:

if (bracket && !self.text.bracketsBlanced) {
    if (spaceBased) {
        [refill appendFormat:@"\n%@}", [NSString stringWithSpaces:spaces - spaceZero]];
    } else {
        [refill appendFormat:@"\n%@}", [NSString stringWithTabs:tabs - tabZero]];
    }
}

这个 reindent 暂时就这样了,没有特别复杂的逻辑。

# 符号面板

还是那个问题,移动设备上输入符号比较困难,所以要做一个方面输入符号的面板,方案很多,我的实现是使用 inputAccessoryView 来实现,好处是他会自动贴紧键盘的边缘,不用去做监听键盘高度等琐事。我的 inputAccessoryView 包含了一个可上下滚动的 UIScrollView,用于三种输入:

  • 撤销和重做
  • 直接插入一个符号
  • 插入一对符号,例如括号和引号

撤销和重做,直接调用 UITextView.undoManager undoredo 方法,并且可以根据 canUndocanRedo 来改变按钮是否可以点击。

直接插入一个符号非常简单,使用:

[self replaceRange:self.selectedTextRange withText:text];

千万别替换整个 text,这样重新渲染的速度很慢,可能造成闪烁。

插入一对符号,在之前的基础上,再增加一个把光标往前移动一格的操作,让用户可以直接在中间输入:

self.selectedRange = NSMakeRange(MAX(0, self.selectedRange.location - 1), 0);

# 光标移动

这部分尚未实现,主要是针对没有 3D Touch 的一个优化。众所周知的是,有 3D Touch 的设备用力按键盘的时候,可以快速移动光标,第二次用力按甚至可以选择文本,像是这样:

非 3D Touch 的设备没有这样的福利。而编写代码的时候移动光标是非常高频的一个操作,所以很有必要在实现一个快速移动光标的功能。

移动光标可以通过 setSelectedRange 实现,交互上可以使用 UIPanGestureRecognizer,效果会比 3D Touch 稍差,这里就不在赘述细节了。

# 定制 highlightjs

由于我在写一个基于 JavaScriptCore 的 js 扩展 library,所以有必要让用户在输入我自定义的一些 JSExport 的时候,也会有高亮显示。例如截图中的 appwidget 就是两个需要额外高亮的关键字。这一部分很简单,需要稍稍理解一下 highlightjs 是如何工作的。highlightjs 通过这样一组 JSON 来定义关键字和内置函数的高亮:

{
  "keyword": "in of if for while finally var new function do return void else break catch instanceof with throw case default try this switch continue typeof delete let yield const export super debugger as async await static import from as",
  "literal": "true false null undefined NaN Infinity",
  "built_in": "eval isFinite isNaN parseFloat parseInt decodeURI decodeURIComponent encodeURI encodeURIComponent escape unescape Object Function Boolean Error EvalError InternalError RangeError ReferenceError StopIteration SyntaxError TypeError URIError Number Math Date String RegExp Array Float32Array Float64Array Int16Array Int32Array Int8Array Uint16Array Uint32Array Uint8Array Uint8ClampedArray ArrayBuffer DataView JSON Intl arguments require module console window document Symbol Set Map WeakSet WeakMap Proxy Reflect Promise"
}

根据你自己的需求,在需要的语言配置上面,keyword 或者 built_in 这里增加你要高亮的关键字即可。

另外就是去掉你不需要的部分,比如不需要换主题功能的话,去掉多余的 css,不需要其他语言(例如我只要 js)的话,就去掉其他语言的配置,highlightjs.org 上面可以定制,你也可以根据自己的需求把 js 和 css 文件压的更小,最后我的加起来只有 11KB

以上就是一个简易可用的代码编辑器的一些思考,感谢观看。

PS: 我的 Retriever 项目已经使用一部分代码:cyanzhong/Retriever

- EOF -

编辑于 2017-01-17

文章被以下专栏收录

    分享 Cocoa 开发中遇到的坑和有趣的事,付费社区:https://xiaozhuanlan.com/devnotes