0
点赞
收藏
分享

微信扫一扫

字节工程师自行开发了基于IntelliJ的终极文档套件


简介:大家好,我是枫哥🌟一线互联网的IT民工、📝资深面试官、🌹Java跳蚤网课堂创始人。拥有多年一线研发经验,曾就职过科大讯飞、美团网、平安等公司。在上海有自己小伙伴组建的副业团队,目前业余时间专注Java技术分享,春招/秋招/社招/跳槽,一对一学习辅助,项目接活开发。


目录

​​前言​​

​​方案总览​​

​​线下效果​​

​​编辑体验​​

​​浏览体验​​

​​线上效果​​

​​技术实现​​

​​基建​​

​​语言基础实现​​

​​语法树单向对应​​

​​生成文档文本​​

​​IDEA Plugin​​

​​语言优化​​

​​MarkdownX​​

​​Gradle / Dokka Plugin​​

​​飞书适配​​

 

前言

众所周知,程序员最讨厌的四件事:写注释,写文档,别人不写注释,别人不写文档。因此,有必要找到降低文档编写和维护成本的方法。目前写技术文档的模式如下:

字节工程师自行开发了基于IntelliJ的终极文档套件_android

 

痛点总结有三个方面:

字节工程师自行开发了基于IntelliJ的终极文档套件_Markdown_02

针对上述问题,我们的解决方案:

  • 本地编辑,浏览工作收敛到IDE,提供身临其境的体验;
  • 在文档和代码之间建立强关联,减少复制,提高联动性,提高文档触摸率;
  • 代码和文档属于Git仓库,借助版本管理,避免因业务迭代而导致文档版本与代码不匹配;
  • 制作可以将文档导出到在线的工具,浏览器可以随时访问;

方案总览

字节工程师自行开发了基于IntelliJ的终极文档套件_Markdown_03

与原始模式相比,新方案可以做到完全脱离浏览器 / 文档编辑器,线上页面的同步完全交给定时触发的自动化部署。

图中橙色部分是方案的重点,按分工分为线下和线上两部分。职责如下:

  • 线下:IDEAPlugin。
  1. 实现自定义语言的分析和分析;
  2. 预览器、编辑器提供文档内容;
  3. 提供一系列实用功能,相关代码和文档;
  • 线上:Gradle/DokkaPlugin。
  1. 桥接,重用IDEPlugin语义分析,生成预览内容的能力;
  2. 扩展Dokarenderer,实现HTML和飞书文档的导出能力;

方案建设采用了许多有趣的技术,后面详细介绍。

线下效果

IDEAPlugin提供侧边栏和强大的编辑器。以下从编辑和浏览两个角度介绍。

编辑体验

假设源代码如下:

public class ClassA {

public static final String TAG = "tag";



ClassB b;



/**

* method document here.

*

* @param params input string

*/

public static void invoke(@NotNull String params) {

System.out.println("invoke method!");

System.out.println("this is method body: " + params);

}



public ClassA() {

System.out.println("create new instance!");

}



private static final class ChildClass {



/**

* This is a method from inner class.

*/

void innerInvoke() {

System.out.println("invoke method from child!");

}

}

}

此效果是在文档中添加此类引用:

字节工程师自行开发了基于IntelliJ的终极文档套件_Markdown_04

 

与复制、粘贴代码不同,新方案具有以下优点:

  • 相关性更强,预览会随着代码片段的变化而变化;
  • 易于重构、引用类名、方法名、字段名重命名时,文档内容会自动改变,防止引用失效;
  • 更直观,编辑,浏览时可以更快地找到代码源;
  • 输入流畅,补充能力提高;

浏览体验

字节工程师自行开发了基于IntelliJ的终极文档套件_HTML_05

 

  • 新方案比普通Markdown更友好:
  • 沉浸式使用,界面嵌入IDE,无需跳转到其他应用;
  • 提到的源代码旁边有行标,点击一键查看文档;
  • 文档浏览器支持与IDE一致的代码亮度,引用跳转;

线上效果

代码中文档会定期自动部署到远端。以一篇真实业务文档举例,HTML 部署到轻服务后长这样:

字节工程师自行开发了基于IntelliJ的终极文档套件_apache_06

对应飞书的产物长如下:

字节工程师自行开发了基于IntelliJ的终极文档套件_Markdown_07

 

这些线上页面主要面向非当前团队的读者,内容由 CI 定时同步,暂不提供跳转到 IDE 的能力。

技术实现

项目的架构如图所示:

字节工程师自行开发了基于IntelliJ的终极文档套件_java_08

 

考虑到用户体验部分主要呈现在IDEA(AndroidStudio)中,我们的技术栈选择基于InteliJ。按模块可分为三部分:

  • 基建层
  • IDEA Plugin
  • Gradle / Dokka Plugin

通用逻辑(语言实现相关)封装在基建层,仅依赖 IntelliJ Core。相对于 IntelliJ Platform,IntelliJ Core 仅保留语言相关的能力,精简了 codeInsight、UI 组件等代码,被广泛用于 IntelliJ 各大产品中(包括图中的 Kotlin、Dokka 等)。

以下将介绍这三个主要模块。

基建

在整个方案中,基础设施是所有功能的基石,其核心能力是建立代码和文档之间的关联。在这里,我们设计了一套标记语言CodeRef,以满足以下需求:

  • 语法简洁,结构与源代码一一对应;
  • 准确的指向,即必须满足一对一的关系;
  • 支持只保留声明(去除body),提高信噪比;
  • 具有扩展性,便于后续迭代新功能;

Coderef语言并不复杂,采用类似Kotlin/Java的风格,用关键字、字符串、括号构成句子和代码块,代码块中的每个节点都有相应的源代码节点。下图是一个简单的示例,相应的关系用着色文字标记:

字节工程师自行开发了基于IntelliJ的终极文档套件_java_09

注:即使文档内容不改变,一旦图片中的源代码部分发生变化,相应的渲染效果也会实时改变,产生动态绑定效果。那么,如何实现动态绑定呢?大致分为以下三个步骤:

  • 设计语法,编写语言实现;
  • 结合现有能力(InteliJCore、KotlinPlugin)获取双边语法树,从而建立从文档节点到源代码节点的单向对应关系;
  • 结合现有能力(MarkdownParser)生成用于渲染的文档文本;

语言基础实现

基于InteliJPlatform,实现自定义语言至少要做以下几件事:

  • 编写BNF定义,描述语法;
  • Parser、Psielement接口、flex定义等。
  • 基于生成的flex文件和JFlex生成lexer;
  • 用Psitreutil等工具编写Mixin,实现PSI中声明的自定义方法;

BNF是一切的基础,每个定义和价值的选择都非常重要。一个小例子:

{

/* ...一些必要的 Context */

tokens = [

/* ...一些 Token,转换为代码中的 IElementType */

AT='@'

CLASS='class'

]

/* ...一些规则 */

extends("class_ref_block|direct_ref|empty_ref") = ref

extends("package_location|class_location") = ref_location

extends("class_ref|method_ref|field_ref") = direct_ref

}



ref_location ::= package_location | class_location

package_location ::= AT package_def {

pin=2 // 只有 '@' 和 package_def 一起出现时,才把整个 element 视为 package_location

}

class_location ::= AT class_def {

pin=2 // 只有 '@' 和 class_def 一起出现时,才把整个 element 视为 class_location

}



direct_ref ::= class_ref | method_ref | field_ref | empty_ref {

methods = [ // 一些自定义的 method,需要在下面指定的 mixin class 中给出实现

getNameStringLiteral

getReferencedElement

getOptionalArgs

]

mixin="com.bytedance.lang.codeRef.psi.impl.CodeRefDirectRefMixin"

}



class_ref ::= CLASS L_PAREN string_literal [COMMA ref_args_element*] R_PAREN {

methods = [

property_value=""

]

pin=1 // 即遇到第一个元素 class 后,就将当前 element 匹配为 class_ref

}

上面的小片段中定义了 @class("")、@package("")、class("", ...) 语法。实战中比较关键的是 pin 和 recoverWhile,前者影响一段“未完成”的代码的类型,后者控制一段规则何时结束。具体参考 Grammar-Kit。

编写完成后,我们可以使用Grammar-Kit生成Parser和Lexer。前者负责最基本的语法亮度,后者负责输出PSI树。在自定义的ParserDefinition中注册两者,然后结合自定义的LanguageFiletype,IDE将相应类型的文件分析为由Psielement组成的树。示意图如图所示: 

 

字节工程师自行开发了基于IntelliJ的终极文档套件_apache_10

 

 

值得一提的是,后续Formatter、CompletionContributor等组件的实现受上述过程的影响很大,如果实现不好,必然会面临返工。但是有很多坑需要一个一个流过。这部分仅限于空间,不能写得太细。有兴趣看看Fortran的BNF定义,语言特征相对简单。

语法树单向对应

考虑到IDE内置对Java和Kotlin语言的支持,以及上一步的结果,我们得到了两棵语法树,是时候连接两棵树的节点了:

字节工程师自行开发了基于IntelliJ的终极文档套件_Markdown_11

 

在这里,我们借用PsireferenceContributor(官方文档)注册Crelement(即Coderef语言Psielement的基类)引用源代码Psielement,基于每行双引号内容(字符串)。如何找到每个字符串对应的元素?遵循以下三个步骤:

  1. 除根节点外,每个节点还需要向上递归,找到每个级别的parent,直到根节点;
  2. 根节点是给定full-qualified-name的package或class,上一步的结果可以确定元素在package或class中的位置;
  3. 通过JavaPsiFacade和一系列搜索方法确定源中对应的Psielement;

注意:Kotlin Plugin 提供一套针对 Java 的 “Light” PsiElement 实现,因此这里我们考虑 Java 即可。

生成文档文本

有了语法树的对应关系,可以生成用于预览的文本。这部分比较常规,要时刻注意读写环境,按照以下步骤实现:

  1. 为每个Coderef语法树根节点指向的源代码文件创建副本;
  2. 遍历CodeRef树中的每一个Ref或Location,创建或定位副本中的相应位置,并将源代码文件中的元素(修改后)复制到副本中;
  3. 导出副本字符串;

考虑到 IDE 中 PSI 和文件是实时映射的,为不影响原文件内容,必须在副本环境中进行语法树的增删改。

虽然这部分不难,但繁琐程度最高。一方面,由于需要深入细节,上述KotlinLightPSI不再适用,因此必须分别为Java和Kotlin编写和实现。另一方面,如何保证复制后的代码格式正确也是一个大问题,尤其是元素之间的注释。最后,文本内容的生成在不断的断点和调试周期中形而上学地完成。

到目前为止,基建层的任务——将CodeRef还原为代码段——全部完成。

IDEA Plugin

有了前面的基础,IDEAPlugin主要负责使方案的本地使用体验可用易用。具体来说,插件的功能分为两类:

  1. 丰富语言功能的CodeRef;
  2. 面向Markdown,改进编辑,阅读体验;
  3. 接下来分别从以上角度介绍。

语言优化

对于一门新语言来说,从体验层面来说,PSI的完成只是第一步,自动完成、关键词亮度、格式化等功能对可用性的影响也是决定性的。尤其是在Coderef语法下,指望用户不依靠提示手动输入正确的包名、类名、方法名无疑太硬核了。让我们选择一些有趣的开始。

代码补全

在IDEA中,大多数(不太复杂的)代码都是通过Pattern模式注册的。所谓Pattern相当于Filter,当前光标位置满足Pattern时,会触发相应的ComplternConContributor。

我们可以用PlatformPaterns的几种内置方法来描述Pattern。例如,一个Coderef代码:method(“helloworld”),它的PSI树长如下:

- CrMethodRef          // text: method("helloWorld")

- CrStringLiteral // text: "helloWorld"

- LeafPsiElement // text: helloWorld

Pattern 因此为:

val pattern = PlatformPatterns.psiElement()

.withParent(CrStringLiteral::class.java)

.withSuperParent(2, CrMethodRef::class.java)

对应每一个Pattern,我们需要实现一个CompletionProvider给出补充信息,比如一个固定返回关键字补充的Provider:

val keywords = setOf("package", "class", "lang")

class KeywordCompletionProvider : CompletionProvider<CompletionParameters>() {

override fun addCompletions(

parameters: CompletionParameters,

context: ProcessingContext,

result: CompletionResultSet

) {

keywords.forEach { keyword ->

if (result.prefixMatcher.prefixMatches(keyword)) {

// 添加一个 LookupElementBuilder,可以指定简单的样式

result.addElement(LookupElementBuilder.create(keyword).bold())

}

}

}

}

掌握上述技能,如class、package、method等关键字,甚至方法名和字段名的补充都很容易实现。

比较 trick 的是包名和带有包名的类名的补全,它们形如 a.b.c.DEF。不同的是,每次输入 '.' 都会触发一次补全,而且要求在字符串开头直接输入“DE”也能正确联想并补全。限于篇幅不展开介绍了,详见 com.intellij.codeInsight.completion.JavaClassNameCompletionContributor 的实现。

格式化

在格式化方面,IDEA并没有直接使用PSI或ASTNode,而是建立了基于两者的Block系统。所有缩进和间距的调整都是以Block为最小粒度进行的(有些复杂的语言拆得太细,可以很好的降低设计的复杂性,很棒)。

这里概念不多,列举如下:

  • ASTBlock:我们用现有的ASTNode树构建Block,所以继承这个基础;
  • Indent:控制每行的缩进;
  • Spacing:控制每个Block之间的间距策略(最小、最大空间、强制换行/不换行等)。
  • Wrap:单行长度过长的折行策略;
  • Alignment:自己在ParentBlock中的对齐方向;

实际敲击代码时,大部分时间都花在getSpacing方法上,写出来的效果类似于这样:

override fun getSpacing(child1: Block?, child2: Block): Spacing? {

/*...*/

return when {

// between ',' and ref

node1?.elementType == CodeRefElementTypes.COMMA && psi2 is CrRef ->

Spacing.createSpacing(/*minSpaces*/0, /*maxSpaces*/0, /*minLineFeeds*/1, /*keepLineBreaks*/true, /*keepBlankLines*/1)

// between '[', literal, ']'

node1?.elementType == CodeRefElementTypes.L_BRACKET && psi2 is CrStringLiteral ||

psi1 is CrStringLiteral && node2?.elementType == CodeRefElementTypes.R_BRACKET ->

Spacing.createSpacing(/*minSpaces*/0, /*maxSpaces*/0, /*minLineFeeds*/0, /*keepLineBreaks*/false, /*keepBlankLines*/0)

}

}

格式化属于说起来很简单,实现起来很头痛的东西。实操过程中,被迫把前面写好的 BNF 做了一波不小的调整,才达到理想效果。好在我们的语言比较简陋简洁,没踩到什么大坑,如果面向更复杂的语言,工作量将是指数级提升(参考 com.intellij.psi.formatter.java 包下的代码量)。

MarkdownX

上面列出了这么多内容,说白了就是Markdown中代码块的增强方案,然后Coderef和Markdown终于要合体了。

事实上,官方一直支持Markdown(IDEA内置,AS可选安装),包括一套完整的语言实现和编辑器,预览器。以下是预览的生成过程,如图所示:

字节工程师自行开发了基于IntelliJ的终极文档套件_Markdown_12

分为以下步骤:

  1. 利用MarkdownParser将文本分析为多个ASTNode;
  2. 利用HtmlGenerator内置的visitor访问每个ASTNode生成HTML文本;
  3. 将生成的HTMLDocument设置为内置浏览器(如有),最终呈现在屏幕上;

交代个背景:在本项目启动之初,IDEA 正处于 JavaFX-WebView 到 JCEF 的过渡期(直接导致了 AndroidStudio 4.0 左右的版本没有可用的内置 WebView 实现)。

上述方案总结有以下问题:

  1. 兼容性差,部分IDE版看不到预览;
  2. 每一次MD变更都会触发完整的generateHtml,如果文档内容复杂度高,就会出现性能瓶颈;
  3. 将HTML文本set交给浏览器时,没有diff逻辑,会触发页面reload,这也可能导致性能问题(后来diff能力增加到带JCEF的IDE,但并非所有IDE都内置JCEF);

综合考虑,我们决定不直接使用本地插件,而是基于其创建新语言MarkdownX,最大限度地重用原有能力,增加对Coderef的支持,并根据Swing制作一套类似Recyclerview的机制来提高预览性能。

优化后的方案流程类似:

字节工程师自行开发了基于IntelliJ的终极文档套件_android_13

自制方案有许多优点:

  • 内存占用较低(浏览器vs.JComponent)
  • 性能更好(局部刷新、控件复用等)
  • 更好的体验(浏览器内置对标签的支持太基础,无法实现代码亮度、引用跳转等功能,本地控件没有这些限制)
  • 更好的兼容性(不解释)

CodeRef 支持

Markdownx只表现为新语言,MarkdownParser和HtmlGenerator在实现中仍然被重用,主要区别在于文件扩展名和code-fence的处理。

所谓code-fence,就是用``符号包裹在Markdown中的代码块。与本地实现不同,我们需要在生成预览时更换代码块的内容,并使内容随代码的变化而变化。

实际上,我们需要实现一个org.intellij.markdown.html.generatingProvider,简写如下:

class MarkDownXCodeFenceGeneratingProvider : GeneratingProvider {

override fun processNode(visitor: HtmlGenerator.HtmlGeneratingVisitor, text: String, node: ASTNode) {

visitor.consumeHtml("<pre>")

var state = 0 // 用于后面遍历 children 的时候暂存状态

/* ...一些变量定义 */

for(child in childrenToConsider) {

if (state == 1 && child.type in listOf(MarkdownTokenTypes.CODE_FENCE_CONTENT, MarkdownTokenTypes.EOL)) {

/* ...拼接每行内容 */

}

if (state == 0 && child.type == MarkdownTokenTypes.FENCE_LANG) {

/* ...记录当前 code-fence 的语言 */

applicablePlugin = firstApplicablePlugin(language) // 找到可以处理当前语言的“插件”

}

if (state == 0 && child.type == MarkdownTokenTypes.EOL) {

/* ...进入代码段,设置状态 */

state = 1

}

}

if (state == 1) {

visitor.consumeTagOpen(node, "code", *attributes.toTypedArray())

if (language != null && applicablePlugin != null) {

/* ...命中自定义处理逻辑(即 CodeRef)*/

visitor.consumeHtml(content) // 即由自定义逻辑生成的 Html

} else {

visitor.consumeHtml(codeFenceContent) // 默认内容

}

}

/* ...一些收尾 */

}

}

可此可见,当前代码段的语言可以在遍历node的children后确定。若语言为coderef,则进入上述预览文本生成逻辑,最后通过visitor(相当于HTMLBuilder)将定制内容拼接到Html中。

预览性能优化

考虑到JList没有item回收的能力,我们选择在List实现中直接使用Box。处理过程如下:

字节工程师自行开发了基于IntelliJ的终极文档套件_HTML_14

机制分为两大步:

  1. Data层将HTML的body分成几个部分,diff后通知View层更改;
  2. View层将变更的数据设置到List对应的位置,并尽可能重用现有的ViewHolder。该过程可能涉及创建和删除ViewHolder;

目前,我们为文本、图片和代码创建了三种ViewHolder:

  1. 文本:使用JTextPane与HTML+CSS一起恢复文本样式;
  2. 图片:自定义JComponent缩放,绘制,确保图片居中并完整显示;
  3. 代码:基于IDE提供的Editor,进行必要的设置和逻辑简化;

在这里,处理Editor花费了大量精力:

  1. 使用原始代码文件作为context创建PsicodeFragment作为内容填充Editor,以确保代码中的原始文件import类别、方法、字段可以正常resolve(这一点非常重要,如果使用MockDocument作为内容,绝大多数代码亮度和跳转不生效);
  2. 设置合适的HighlightingFilter,确保不报红(将原文件作为context的代价是当前代码片段的类很可能被认为是类重复,代码结构不一定合法,因此需要禁用报红级别的代码分析);
  3. 禁用Intention,设置只读(提高性能,减少干扰);
  4. 禁止Inspection和ExternalAnnotator;(两者都是性能消耗大户,后者包括AndroidLint相关逻辑)

经过以上优化,测量预览在大多数情况下可以顺利显示和刷新。但如果同时打开多个文档,或者操作速度惊人,还是会时不时卡住很久。分析发现,性能消耗主要在HTML生成上。

由于Markdown语法限制(节点深度低),传统MD转HTML的性能支出有限。但是回顾以上,我们对coderef的处理会伴随着大量的PSIresolve,复杂飙升,频繁的全generate就没那么合适了。一个很自然的想法是给每个coderef添加缓存,内容不变的时候直接使用缓存内容。这样,在修改文本段落时,可以完全避免其他文件的语法分析,在修改coderef段落时,只会刷新当前代码块的内容。

然后问题来了:如果用户修改的代码不是文档文件,而是被引用的代码,预览不会在缓存的作用下立即改变。那么进一步,如果你注册并监控所有被引用的文件,并在更改时刷新缓存,问题能解决吗?其实这样做的问题确实解决了,但是引入了新的问题:如何释放文件监控?

此处插入背景:对 code-fence 内容的干预是基于 Visitor 模式回调完成的,因此作为 generator 本身是不知道本次处理的代码块与前一次、后一次回调是否由同一个变更引起。举个例子:一个文档中有 A、B、C 三个 codeRef 块,则在一次 HTML 生成过程中,generator 会收到三次回调,且没有任何手段可以得知这三次回调的关联性。

目前,我们只能在HTML生成前后通知generator,并在generator内部维护一个队列+计数器,以不那么优雅地解决泄漏问题。

至此,插件的整体性能终于在可接受范围内。

Gradle / Dokka Plugin

为了让受众更广、内容随时可读,把文档做到可导出、可自动化部署是非常必要的。方案上,我们选用同为 IntelliJ 出品的 Dokka 作为基础框架,利用其完善的数据流变换能力,高效地适配多输出格式的场景。

Dokka 流程扩展

Dokka 作为同时兼容 Kotlin 和 Java 的文档框架,“数据流水线”的思想和极强的可扩展性是其特点。代码转换到文档页面的流程如下:

字节工程师自行开发了基于IntelliJ的终极文档套件_apache_15

每个节点都有至少一个 Extension Point,扩展起来非常灵活。

图中几个主要角色列如下:

  1. Env:包含基于KotlinCompiler和IntelliJ-Core扩展的代码分析器(用于输出DocumentModels)、开发者定制的插件等组件;
  2. DocumentModels:对module、package、class、function、fields等元素的抽象,呈树形组织,本质上是一些dataclass;
  3. PageModels:PageCreator以DocumentModels为输入,创建的一系列对象是封装页面,描述页面的结构;
  4. Renderer:用于将PageModels渲染成某种格式的产品(Dokka内置HTML、Markdown等););

从以上内容可以看出,Doka最初的功能只是将代码转换为文档页面,而不是本地支持文档文件的转换(真的没有必要)。但是在我们的场景中,MarkdownX的渲染取决于源代码信息,所以Doka的这部分ka的这部分能力。

通过重写PageCreator,我们将包含Markdownx文档的项目变成这样一个节点树:

字节工程师自行开发了基于IntelliJ的终极文档套件_java_16

 

  • MdxDirNode 对应文件夹节点,页面内容是当前文件夹的目录,点击链接可跳转至下一级;
  • MdxPageNode 对应 MarkdownX 文档内容,包含若干类型的 children 分别代表不同类型的内容片段;

在创建MdxPagenode时,我们使用类似于上述IDEA-plugin的方法,重写一个org.jetbrains.doka.barsers.parserser,修改code-fence的处理,改为调用到基础设施部分生成coderef预览文本的代码,最终获得所需的文档文本。

飞书适配

在获得页面内容后,结合Doka自带的HTMLRenderer,很容易输出可用于部署的HTML产品。但目前的情况是,我们更喜欢将文档收敛到飞行书籍中,这需要为飞行书籍编写另一份定制Renderer。

考虑到自己处理页面的树形结构过于复杂,我们实际上是基于内置的Defaultrender基类来扩展的:

abstract class DefaultRenderer<T>(

protected val context: DokkaContext

) : Renderer {

abstract fun T.buildHeader(level: Int, node: ContentHeader, content: T.() -> Unit)

abstract fun T.buildLink(address: String, content: T.() -> Unit)

abstract fun T.buildList(

node: ContentList,

pageContext: ContentPage,

sourceSetRestriction: Set<DisplaySourceSet>? = null

)

abstract fun T.buildNewLine()

abstract fun T.buildResource(node: ContentEmbeddedResource, pageContext: ContentPage)

abstract fun T.buildTable(

node: ContentTable,

pageContext: ContentPage,

sourceSetRestriction: Set<DisplaySourceSet>? = null

)

abstract fun T.buildText(textNode: ContentText)

abstract fun T.buildNavigation(page: PageNode)



abstract fun buildPage(page: ContentPage, content: (T, ContentPage) -> Unit): String

abstract fun buildError(node: ContentNode)

}

上面只列出一部分了回调方法。

可以看出,这种接口方式比较新颖:以Visitor的方式遍历页面节点树,然后为开发者提供一系列Builder/DSL风格的待实现方法。对于这些abstractfunction,内置Htmlrender采用kotlinx.html(DSL风格的HTML构建器)实现,这意味着我们也应该实现一套DSL风格的飞行文档构建器。


DSL部分不详细,这里主要讲飞书的文档结构。众所周知,Markdown在设计之初就是面向Web的,所以天生就有与HTML互动的能力。但飞书文档的数据结构相对更像Pdf、Docx等文件,层次有限,相对扁平。例如,在相同的文档内容中,MdxPagenode的结构是这样的:

字节工程师自行开发了基于IntelliJ的终极文档套件_android_17

而飞书的结构长这样:

字节工程师自行开发了基于IntelliJ的终极文档套件_android_18

可以看出,差异是巨大的。这部分差异的抹平完全取决于自定义的Feishurenderer,具体做法只能由casebycase介绍,仅限于篇幅不展开,一般思路是对不兼容的节点进行展开或合并,穿插必要的子树遍历。

以下是两个特殊点:图片和链接。

文档链接

写Markdown文档时,往往需要插入链接,指向其他Markdown文档(一般使用相对路径)。这时候就需要想办法把相对路径映射成飞书链接,需要在Render步骤之后进行,因为映射的时候需要知道相应文档的飞书链接是什么。

第一反应必须是对文档进行拓扑排序,并根据依赖关系逐一上传文档。但这需要文档之间没有循环依赖,这显然不能保证(两个文档相互引用相当常见)。幸运的是,飞行文档提供了修改文档的界面,因此我们可以提前创建一批空文档,在获得空文档的链接后更换相对路径。换句话说,文档上传的处理过程是:创建空文档->替换相对路径为相应的文档链接->修改文档内容。

图片

图片可以在Markdown中与文本并列,属于Paragraph的一种。在飞行图书文档结构中,图片属于Gallery,只能独占一行,不能与文本同行。这两种格式在实现中不能完全兼容。目前的初步实现方案是在Paragraph的Group入口处向下DFS,找到所有图片,提出放在文本前面。效果,只能忍受。

顺便说一下,图片也需要上传和替换逻辑,类似于文档链接,不重复。

结语

以上是文档套件的全部内容:基于InteliJ技术栈,我们通过设计新语言、编写IDE插件、Gradle/Dokka插件,形成了完整的文档辅助解决方案,有效建立了文档与代码的关联,大大提高了编写和阅读体验。

未来,我们将为框架引入更实用的改进,包括:

  • 添加图形代码元素选择器,降低语言学习和使用成本;
  • 优化预览渲染效果,对齐WebView;
  • 探索部分框架(Dagger、Retrofit等)的文档自动生成能力。

目前框架还处于内部测试阶段,正在逐步扩大推广范围。方案成熟,功能稳定后,将整体开源方案,为更多用户服务,吸收社区Idea。请期待!


举报

相关推荐

0 条评论