位元組工程師自研基於 IntelliJ 的終極文件套件

語言: CN / TW / HK

前言

眾所周知,程式設計師最討厭的四件事:寫註釋、寫文件、別人不寫註釋、別人不寫文件。因此,想辦法降低文件的編寫和維護成本是很有必要的。當前寫技術文件的模式如圖:

圖片

痛點總結有如下三方面:

圖片

針對上述問題,我們的解決思路:

  • 本地的編輯、瀏覽工作收斂至 IDE,提供沉浸式體驗;
  • 在文件、程式碼間建立強關聯,減少拷貝,提升聯動性,同時提升文件的觸達率
  • 程式碼與文件同屬一個 Git 倉庫,藉助版本管理,避免因業務迭代導致的文件版本與程式碼不匹配
  • 製作可將文件匯出到線上的工具,可利用瀏覽器做到隨時訪問

方案總覽

圖片

與原始模式相比,新方案可以做到完全脫離瀏覽器 / 文件編輯器,線上頁面的同步完全交給定時觸發的自動化部署。

圖中橙色部分是方案的重點,按照分工,劃分為線下、線上兩部分,職責如下:

  • 線下:IDEA Plugin
    • 實現自定義語言的解析、分析;
    • 提供文件內容的預覽器、編輯器;
    • 提供一系列實用功能,關聯程式碼與文件;
  • 線上:Gradle / Dokka Plugin
    • 橋接、複用 IDE Plugin 的語義分析、預覽內容生成能力;
    • 擴充套件 Dokka Renderer,實現 HTML 與飛書文件的匯出能力;

方案建設使用了不少有意思的技術,放到後面詳細介紹。

線下效果

IDEA Plugin 提供一個側邊欄和強大的編輯器。下面分別從編輯、瀏覽兩個角度介紹。

編輯體驗

假設存在原始碼如下:

``` 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!");         }     } } ```

文件中新增該類的引用就是這個效果:

圖片

不同於複製、貼上程式碼,新方案有如下優勢: - 關聯性更強,預覽會隨程式碼片段的變更時時改變; - 易於重構,被引用的類名、方法名、欄位名發生重新命名時,文件內容會自動隨之變化,防止引用失效; - 更加直觀,編輯、瀏覽時能更快速地找到程式碼出處; - 輸入更流暢,有完善的補全能力;

瀏覽體驗

圖片

相對於普通 Markdown,新方案用起來更加友善:

  • 沉浸式使用,介面內嵌在 IDE 內,無需跳轉到其他應用;
  • 被提及的原始碼旁均有行標,點選一鍵查閱文件;
  • 文件“瀏覽器”支援與 IDE 一致的程式碼高亮、引用跳轉;

線上效果

程式碼中文件會定期自動部署到遠端。以一篇真實業務文件舉例,HTML 部署到輕服務後長這樣:

圖片

對應飛書的產物長這樣:

圖片

這些線上頁面主要面向非當前團隊的讀者,內容由 CI 定時同步,暫不提供跳轉到 IDE 的能力。

技術實現

專案的架構如圖所示:

圖片

考慮到使用者體驗部分主要在 IDEA(Android Studio)內呈現,我們的技術棧選擇基於 IntelliJ 打造。按模組可分為三部分:

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

通用邏輯(語言實現相關)封裝在基建層,僅依賴 IntelliJ Core。相對於 IntelliJ Platform,IntelliJ Core 僅保留語言相關的能力,精簡了 codeInsight、UI 元件等程式碼,被廣泛用於 IntelliJ 各大產品中(包括圖中的 Kotlin、Dokka 等)。

下面將針對這三個主要模組展開介紹。

基建

縱觀整個方案,基建層是所有功能的基石,其最核心的能力是建立程式碼與文件關聯。這裡我們設計實現了一套標記語言 CodeRef,滿足以下幾個需求:

  • 語法簡潔,結構上與原始碼一一對應;
  • 指向精準,即必須滿足一對一的關係;
  • 支援僅保留宣告(去掉 body),提升信噪比;
  • 有擴充套件性,方便後續迭代新功能;

CodeRef 語言並不複雜,採用類似 Kotlin/Java 的風格,用關鍵字、字串、括號構成語句和程式碼塊,程式碼塊中每個節點都有與之對應的原始碼節點。下圖是一個簡單的示例,對應關係用著色文字標識:

圖片

注意:即使不改動文件內容,圖中“原始碼”部分一旦發生變化,對應的渲染效果也會實時發生改變,產生“動態繫結”的效果。那麼如何實現“動態繫結”呢?大致拆解成以下三步:

  1. 設計語法,編寫語言實現
  2. 結合現有能力(IntelliJ Core、Kotlin Plugin)獲取雙邊語法樹,從而建立文件節點到原始碼節點的單向對應關係
  3. 結合現有能力(Markdown Parser)生成用於渲染的文件文字

語言基礎實現

基於 IntelliJ Platform,實現一個自定義語言起碼要做以下幾件事:

  1. 編寫 BNF 定義,描述語法;
  2. 藉助 Grammar Kit 生成 ParserPsiElement 介面、flex 定義等;
  3. 基於生成的 flex 檔案和 JFlex 生成 Lexer
  4. 編寫 Mixin 類用 PsiTreeUtil 等工具實現 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 構成的樹。示意如圖:

圖片

值得一提的是,後續 FormatterCompletionContributor 等元件的實現受上述過程影響極大,實現不好必然面臨返工。而偏偏這裡面又有不少“坑”需要一一淌過,這部分限於篇幅沒辦法寫得太細,有興趣看看語言特性“相對簡單”的 Fortran 的 BNF 定義感受一下。

語法樹單向對應

考慮到 IDE 內建了對 Java、Kotlin 語言的支援,有了上一步的成果,我們就得到了兩顆語法樹,是時候把兩棵樹的節點關聯起來了:

圖片

這裡我們借用 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 和檔案是實時對映的,為不影響原檔案內容,必須在副本環境中進行語法樹的增刪改。

這部分雖然難度不大,繁瑣程度卻是最高的。一方面,由於要深入到細節,使得前文提到的 Kotlin Light PSI 不再適用,因此必須針對 Java 和 Kotlin 分別編寫實現。另一方面,如何保證複製後的程式碼格式仍是正確的也是個大問題,尤其是涉及元素之間穿插註釋的情況。最後,文字內容生成的工作在不停的斷點、除錯的迴圈中玄學般地完成了。

至此,基建層的任務——將 CodeRef 還原成程式碼段——便全部完成了。

IDEA Plugin

有了前面的基礎,IDEA Plugin 主要負責把方案的本地使用體驗做到可用、易用。具體來說,外掛的功能分為兩類:

  1. 面向 CodeRef,豐富語言功能;
  2. 面向 Markdown,提升編輯、閱讀體驗;

接下來分別從以上角度介紹。

語言優化

對於一門“新語言”,從體驗層面來看,PSI 的完成只是第一步,自動補全、關鍵字高亮、格式化等功能對可用性的影響也是決定性的。尤其是在 CodeRef 的語法下,指望使用者能不依賴提示手動輸入正確的包名、類名、方法名,無疑過於硬核了。下面挑幾個有意思的展開說說。

程式碼補全

在 IDEA 中,大部分(不太複雜的)程式碼補全使用 Pattern 模式註冊。所謂 Pattern 相當於一個 Filter,在當前游標位置滿足該 Pattern 時就會觸發對應的 CompletionContributor

我們可以使用 PlatformPatterns 的若干內建方法描述一個 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() {     override fun addCompletions(         parameters: CompletionParameters,         context: ProcessingContext,         result: CompletionResultSet     ) {         keywords.forEach { keyword ->             if (result.prefixMatcher.prefixMatches(keyword)) {                 // 新增一個 LookupElementBuilder,可以指定簡單的樣式                 result.addElement(LookupElementBuilder.create(keyword).bold())             }         }     } } ```

掌握上述技能,諸如 classpackagemethod 等關鍵字,乃至方法名和欄位名的補全就都很容易實現了。

比較 trick 的是包名和帶有包名的類名的補全,它們形如 a.b.c.DEF。不同的是,每次輸入 '.' 都會觸發一次補全,而且要求在字串開頭直接輸入“DE”也能正確聯想並補全。限於篇幅不展開介紹了,詳見 com.intellij.codeInsight.completion.JavaClassNameCompletionContributor 的實現。

格式化

格式化這件事上,IDEA 並沒有直接使用 PSI 或者 ASTNode,而是基於二者建立了一套“Block”體系。所有縮排、間距的調整都是以 Block 為最小粒度進行的(一些複雜語言拆的太細,這樣設計可以很好地降低實現複雜度,妙啊)。

這裡的概念也不多,列舉如下:

  • ASTBlock:我們用現有的 ASTNode 樹構建 Block,因此繼承此基類;
  • Indent:控制每行的縮排;
  • Spacing:控制每個 Block 之間的間距策略(最小、最大空格,是否強制換行 / 不換行等);
  • Wrap:單行長度過長時的折行策略;
  • Alignment:自己在 Parent Block 中的對齊方向;

實際敲程式碼時,大部分時間花在 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 可選安裝),包含一整套語言實現和編輯器、預覽器。這裡重點說說其預覽的生成流程,如圖:

圖片

分為以下幾步(邏輯在 org.jetbrains:markdown 依賴中,未開源):

  1. 利用 MarkdownParser 將文字解析成若干 ASTNode;
  2. 利用 HtmlGenerator 內建的 visitor 訪問每個 ASTNode 生成 HTML 文字;
  3. 將生成的 HTML Document 設定給內建瀏覽器(如果有),最終呈現在螢幕上;

交代個背景:在本專案啟動之初,IDEA 正處於 JavaFX-WebView 到 JCEF 的過渡期(直接導致了 AndroidStudio 4.0 左右的版本沒有可用的內建 WebView 實現)。

上述方案總結有以下問題:

  1. 相容性較差,部分 IDE 版本無法看到預覽;
  2. 每次 MD 的變更都會觸發全量 generateHtml,如果文件內容複雜度較高,將有效能瓶頸;
  3. 將 HTML 文字 set 給瀏覽器時沒有 diff 邏輯,會觸發頁面 reload,同樣可能導致效能問題(後來針對帶有 JCEF 的 IDE 增加了 diff 能力,但並不是所有 IDE 都內建 JCEF);

綜合考慮下,我們決定不直接使用原生外掛,而是基於其建立新的語言“MarkdownX” ,最大程度複用原本的能力,追加對 CodeRef 的支援,同時基於 Swing 自制一套類似 RecyclerView 的機制改善預覽效能。

優化後的方案流程類似這樣:

圖片

自制的方案有很多優勢:

  1. 記憶體佔用更低(瀏覽器 vs. JComponent)
  2. 效能更佳(區域性重新整理、控制元件複用等)
  3. 體驗更佳(瀏覽器內建對<code>標籤的支援過於基礎,無法實現程式碼高亮、引用跳轉等功能,原生控制元件不存在這些限制)
  4. 相容性更佳(不解釋)
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(相當於一個 HTML Builder)將自定義的內容拼接到 Html 中。

預覽效能優化

考慮到 JList 並沒有“item 回收”能力,在 List 實現上我們選擇直接使用 Box。處理流程如下圖:

圖片

機制分為兩大步:

  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(這點很重要,如果用 Mock 的 Document 作為內容,絕大部分程式碼高亮和跳轉都是不生效的);
  2. 設定合適的 HighlightingFilter,確保“沒有報紅”(將原檔案作為 context 的代價是,當前程式碼片段的類極有可能被認為是類重複,並且程式碼結構也不一定合法,因此需要禁用“報紅”級別的程式碼分析);
  3. 禁用 Intention,設定只讀(提升效能,降低干擾);
  4. 禁用 Inspection 和 ExternalAnnotator;(兩者是效能消耗的大戶,後者包括 Android Lint 相關邏輯)

經過上述優化,實測大部分情況下預覽都可以流暢展示 & 重新整理了。但如果同時開啟多個文件,或者“操作速度驚人”,還是會時不時出現長時間卡頓。分析一波發現,效能消耗主要出在 HTML 生成上

由於 Markdown 語法限制(節點深度低),常規的 MD 轉 HTML 效能開銷有限。但回顧上文,我們對 codeRef 的處理會伴隨大量 PSI resolve,複雜度暴漲,頻繁的全量 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 的文件框架,“資料流水線”的思想和極強的可擴充套件性是其特點。程式碼轉換到文件頁面的流程如下:

圖片

每個節點都有至少一個 Extension Point,擴充套件起來非常靈活。

圖中幾個主要角色列舉如下:

  • Env:包含基於 Kotlin Compiler 和 IntelliJ-Core 擴充套件的程式碼分析器(用於輸出 Document Models)、開發者自定義的外掛等元件;
  • Document Models:對 module、package、class、function、fields 等元素的抽象,呈樹形組織,本質是一些 data class;
  • Page Models:由 PageCreator 以 Document Models 為輸入,建立的一系列物件,是對“頁面”的封裝,描述“頁面”的結構;
  • Renderer:用於將 Page Models 渲染成某種格式的產物(Dokka 內建的有 HTML、Markdown 等);

從上述內容可以看出,Dokka 原本的作用只是將程式碼轉換為文件頁面,並不原生支援轉換文件檔案(也確實沒必要)。但在我們的場景下,MarkdownX 的渲染是依賴原始碼資訊的,也就正好能用到 Dokka 的這部分能力。

通過重寫 PageCreator,我們將含有 MarkdownX 文件的工程變成類似這樣的節點樹:

圖片

  • MdxDirNode 對應資料夾節點,頁面內容是當前資料夾的目錄,點選連結可跳轉至下一級;
  • MdxPageNode 對應 MarkdownX 文件內容,包含若干型別的 children 分別代表不同型別的內容片段;

在建立 MdxPageNode 時,我們用類似前文 IDEA-Plugin 的做法,重寫一個 org.jetbrains.dokka.base.parsers.Parser 並修改對 code-fence 的處理,改為呼叫到「基建」部分中生成 CodeRef 預覽文字的程式碼,最終得到所需的文件文字。

飛書適配

得到頁面內容後,結合 Dokka 自帶的 HtmlRenderer,輸出一份可用於部署的 HTML 產物就輕而易舉了。但現狀是,我們更希望能把文件收斂在飛書上,這就需要再編寫一份針對飛書的自定義 Renderer

考慮到自己處理頁面的樹形結構過於複雜,實際上我們基於內建的 DefaultRenderer 基類進行擴充套件:

``` abstract class DefaultRenderer(     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? = null     )     abstract fun T.buildNewLine()     abstract fun T.buildResource(node: ContentEmbeddedResource, pageContext: ContentPage)     abstract fun T.buildTable(         node: ContentTable,         pageContext: ContentPage,         sourceSetRestriction: Set? = 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 風格的待實現方法給開發者。對於這些 abstract function,內建的 HtmlRenderer 採用 kotlinx.html(一個 DSL 風格的 HTML 構建器)實現,這意味著我們也要實現一套 DSL 風格的飛書文件構建器。

飛書開放平臺文件檢視連結:https://open.feishu.cn/document/home/index。

DSL 的部分就不詳述了,這裡主要說說飛書的文件結構。眾所周知,Markdown 在設計之初就是面向 Web 的,因此與 HTML 天生具有互轉的能力。然而飛書文件的資料結構相對更像 Pdf、Docx 這類檔案,擁有有限層級,相對扁平。舉個例子,同樣的文件內容,MdxPageNode 中結構長這樣:

圖片

而飛書的結構長這樣:

圖片

可見差異是巨大的。這部分差異的抹平全靠自定義的 FeishuRenderer,具體做法只能 case by case 介紹,限於篇幅就不展開了,大體思路就是對不相容的節點進行展開或合併,穿插必要的子樹遍歷。

下面提兩個特殊點的處理:圖片和連結。

文件連結

寫 Markdown 文件時,往往需要插入連結,指向其他的 Markdown 文件(一般使用相對路徑)。這時,我們需要想辦法把相對路徑對映成飛書連結,而且需要在 Render 步驟之後進行,因為對映的時候需要知道對應文件的飛書連結是什麼。

第一反應肯定就是對文件做拓撲排序了,按照依賴關係一個個上傳文件。但這樣需要文件間沒有迴圈依賴,顯然這是不能保證的(兩篇文件相互引用還蠻常見的)。幸好,飛書文件提供了修改文件的介面,因此我們可以提前建立一批空文件,獲取到空文件的連結後,再做相對路徑的替換。換句話說,處理文件上傳流程為:建立空文件-> 替換相對路徑為對應文件連結 -> 修改文件內容。

圖片

圖片在 Markdown 中可以和文字並列,屬於 Paragraph 的一種。而飛書文件結構中,圖片屬於 Gallery,只能獨佔一行,無法和文字同行。兩種格式從實現上無法完全相容。當前初步實現方案是在 Paragraph 的 Group 入口向下 DFS,找到所有圖片,單提出來放在文字前面。效果嘛,只能忍忍了。

順便一提,圖片也需要上傳並替換的邏輯,這部分與文件連結相似,不贅述了。

結語

以上就是文件套件的全部內容:我們基於 IntelliJ 技術棧,通過設計新語言、編寫 IDE 外掛、Gradle / Dokka 外掛,形成一套完整的文件輔助解決方案,有效建立了文件與程式碼的關聯性,大幅提升編寫、閱讀體驗

未來,我們會為框架引入更多實用性改進,包括:

  • 新增圖形化的程式碼元素選擇器,降低語言學習、使用成本;
  • 優化預覽渲染效果,對齊 WebView;
  • 探索針對部分框架(Dagger、Retrofit 等)的文件自動生成能力;

目前框架尚處內測階段,正逐步擴大範圍推廣。待方案成熟、功能穩定後,我們會將方案整體開源,以服務更多使用者,同時吸取來自社群的 Idea,敬請期待!

加入我們

我們是位元組跳動直播營收客戶端團隊,專注禮物、PK、直播權益等業務,並深入探索渲染、架構、跨端、效率等技術方向。目前北京、深圳都有大量人才需要,歡迎投遞簡歷至 [email protected] 加入我們!