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

痛點總結有如下三方面:

針對上述問題,我們的解決思路:
-
本地的編輯、瀏覽工作收斂至 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 的風格,用關鍵字、字串、括號構成語句和程式碼塊, 程式碼塊中每個節點都有與之對應的原始碼節點 。下圖是一個簡單的示例,對應關係用著色文字標識:

注意:即使不改動文件內容,圖中“原始碼”部分一旦發生變化,對應的渲染效果也會實時發生改變,產生“ 動態繫結 ”的效果。那麼如何實現“動態繫結”呢?大致拆解成以下三步:
-
設計語法,編寫語言實現;
-
結合現有能力(IntelliJ Core、Kotlin Plugin)獲取雙邊語法樹,從而 建立文件節點到原始碼節點的單向對應關係 ;
-
結合現有能力(Markdown Parser) 生成用於渲染的文件文字 ;
語言基礎實現
基於 IntelliJ Platform,實現一個自定義語言起碼要做以下幾件事:
-
編寫 BNF 定義,描述語法;
-
藉助 Grammar Kit 生成
Parser
、PsiElement
介面、flex 定義等; -
基於生成的 flex 檔案和 JFlex 生成
Lexer
; -
編寫 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
構成的樹。示意如圖:

值得一提的是,後續 Formatter
、 CompletionContributor
等元件的實現受上述過程影響極大,實現不好必然面臨返工。而偏偏這裡面又有不少“坑”需要一一淌過,這部分限於篇幅沒辦法寫得太細,有興趣看看語言特性“相對簡單”的 Fortran 的 BNF 定義感受一下。
語法樹單向對應
考慮到 IDE 內建了對 Java、Kotlin 語言的支援,有了上一步的成果,我們就得到了兩顆語法樹,是時候把兩棵樹的節點關聯起來了:

這裡我們借用 PsiReferenceContributor
(官方文件) 註冊 CrElement
(即 CodeRef 語言 PsiElement
的基類)向原始碼 PsiElement
的引用,依據便是每行雙引號內的內容(字串)。如何找到每個字串對應的元素呢?遵循以下三步:
-
除根節點外,每個節點需要向上遞迴找到每一級 parent 直至根節點;
-
根節點是給定 full-qualified-name 的 package 或 class,由上一步的結果可確定元素在該 package 或 class 中的位置;
-
通過
JavaPsiFacade
和一系列查詢方法確定原始碼中對應的PsiElement
;注意:Kotlin Plugin 提供一套針對 Java 的 “Light”
PsiElement
實現,因此這裡我們考慮 Java 即可。
生成文件文字
有了語法樹對應關係,就可以生成用於預覽的文字了。這部分比較常規,時刻注意讀寫環境,按照以下步驟實現即可:
-
為每個 CodeRef 語法樹根節點指向的原始碼檔案建立副本;
-
遍歷該 CodeRef 樹中每個 Ref 或 Location,建立或定位副本中對應位置,將原始碼檔案中的元素(修飾後)複製到副本中;
-
匯出副本字串;
考慮到 IDE 中 PSI 和檔案是實時對映的,為不影響原檔案內容,必須在副本環境中進行語法樹的增刪改。
這部分雖然難度不大,繁瑣程度卻是最高的。一方面,由於要深入到細節,使得前文提到的 Kotlin Light PSI 不再適用,因此必須針對 Java 和 Kotlin 分別編寫實現。另一方面,如何保證複製後的程式碼格式仍是正確的也是個大問題,尤其是涉及元素之間穿插註釋的情況。最後,文字內容生成的工作在不停的斷點、除錯的迴圈中 玄學般地 完成了。
至此,基建層的任務—— 將 CodeRef 還原成程式碼段 ——便全部完成了。
IDEA Plugin
有了前面的基礎,IDEA Plugin 主要負責把方案的本地使用體驗做到可用、易用。具體來說,外掛的功能分為兩類:
-
面向 CodeRef,豐富語言功能;
-
面向 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<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:自己在 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
依賴中,未開源):
-
利用
MarkdownParser
將文字解析成若干 ASTNode; -
利用
HtmlGenerator
內建的 visitor 訪問每個 ASTNode 生成 HTML 文字; -
將生成的 HTML Document 設定給內建瀏覽器(如果有),最終呈現在螢幕上;
交代個背景:在本專案啟動之初,IDEA 正處於 JavaFX-WebView 到 JCEF 的過渡期(直接導致了 AndroidStudio 4.0 左右的版本沒有可用的內建 WebView 實現)。
上述方案總結有以下問題:
-
相容性較差,部分 IDE 版本無法看到預覽;
-
每次 MD 的變更都會觸發全量 generateHtml,如果文件內容複雜度較高,將有效能瓶頸;
-
將 HTML 文字 set 給瀏覽器時 沒有 diff 邏輯 ,會觸發頁面 reload,同樣可能導致效能問題(後來針對帶有 JCEF 的 IDE 增加了 diff 能力,但並不是所有 IDE 都內建 JCEF);
綜合考慮下,我們決定不直接使用原生外掛,而是 基於其建立新的語言“MarkdownX”
,最大程度複用原本的能力,追加對 CodeRef 的支援,同時基於 Swing 自制一套類似 RecyclerView
的機制改善預覽效能。
優化後的方案流程類似這樣:

自制的方案有很多優勢:
-
記憶體佔用更低(瀏覽器 vs. JComponent)
-
效能更佳(區域性重新整理、控制元件複用等)
-
體驗更佳 (瀏覽器內建對
<code>
標籤的支援過於基礎,無法實現程式碼高亮、引用跳轉等功能,原生控制元件不存在這些限制) -
相容性更佳(不解釋)
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
。處理流程如下圖:

機制分為兩大步:
-
Data 層將 HTML 的 body 拆分成若干部分,diff 後將變更通知給 View 層;
-
View 層將變更的資料設定到 List 對應位置上,並儘可能複用已有的
ViewHolder
。過程可能涉及ViewHolder
的建立和刪除;
目前我們針對文字、圖片和程式碼建立了三種 ViewHolder
:
-
文字 :使用
JTextPane
配合 HTML + CSS 完成文字樣式的還原; -
圖片 :自定義
JComponent
進行縮放、繪製,保證圖片居中且完整展示; -
程式碼 :以 IDE 提供的
Editor
作為基礎,進行必要的設定與邏輯精簡;
這裡對 Editor 的處理花費了大量精力:
-
使用原始碼檔案作為 context 建立
PsiCodeFragment
作為內容填充Editor
,以 保證程式碼中對原檔案 import 過的類、方法、欄位可被正常 resolve (這點很重要,如果用 Mock 的Document
作為內容,絕大部分程式碼高亮和跳轉都是不生效的); -
設定合適的
HighlightingFilter
,確保“沒有報紅”(將原檔案作為 context 的代價是,當前程式碼片段的類極有可能被認為是類重複,並且程式碼結構也不一定合法,因此需要禁用“報紅”級別的程式碼分析); -
禁用
Intention
,設定只讀(提升效能,降低干擾); -
禁用
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<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 風格的待實現方法給開發者。對於這些 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] 加入我們!
- Abase2:位元組跳動新一代高可用 NoSQL 資料庫
- 火山引擎 A/B 測試私有化實踐
- 大型系統儲存層遷移實踐
- 位元組跳動自研高效能微服務框架 Kitex 的演進之旅
- 因果推斷在遊戲個性化數值中的實踐及應用
- 位元組跳動自研高效能微服務框架 Kitex 的演進之旅
- OOP 思想在 TCC/APIX/GORM 原始碼中的應用
- 一文了解位元組跳動如何解決資料 SLA 治理難題
- LL-DASH CMAF 低延遲直播
- 深入理解 OC/C 閉包
- 廣告素材優選演算法在內容營銷中的應用實踐
- ByteDoc 3.0:MongoDB 雲原生實踐
- 深入剖析 split locks,i 可能導致的災難
- 抖音 Android 包體積優化探索:資源二進位制格式的極致精簡
- 分析 Android 耗電原理後,飛書是這樣做耗電治理的
- 抖音 Android 效能優化系列:Java OOM 優化之 NativeBitmap 方案
- iOS StoreKit 2 新特性解析
- 抖音 Android 包體積優化探索:資源二進位制格式的極致精簡
- iOS StoreKit 2 新特性解析
- ByteDoc 3.0:MongoDB 雲原生實踐