前言
众所周知,程序员最讨厌的四件事:写注释,写文档,别人不写注释,别人不写文档。因此,有必要找到降低文档编写和维护成本的方法。目前写技术文档的模式如下:
痛点总结有三个方面:
针对上述问题,我们的解决方案:
本地编辑,浏览工作收敛到IDE,提供身临其境的体验;
在文档和代码之间建立强关联,减少复制,提高联动性,提高文档触摸率;
代码和文档属于Git仓库,借助版本管理,避免因业务迭代而导致文档版本与代码不匹配;
制作可以将文档导出到在线的工具,浏览器可以随时访问;
方案总览
与原始模式相比,新方案可以做到完全脱离浏览器 / 文档编辑器,线上页面的同步完全交给定时触发的自动化部署。
图中橙色部分是方案的重点,按分工分为线下和线上两部分。职责如下:
线下:IDEAPlugin。
实现自定义语言的分析和分析;
预览器、编辑器提供文档内容;
提供一系列实用功能,相关代码和文档;
线上:Gradle/DokkaPlugin。
桥接,重用IDEPlugin语义分析,生成预览内容的能力;
扩展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!");
}
}
}
此效果是在文档中添加此类引用:
与复制、粘贴代码不同,新方案具有以下优点:
相关性更强,预览会随着代码片段的变化而变化;
易于重构、引用类名、方法名、字段名重命名时,文档内容会自动改变,防止引用失效;
更直观,编辑,浏览时可以更快地找到代码源;
输入流畅,补充能力提高;
浏览体验
新方案比普通Markdown更友好:
沉浸式使用,界面嵌入IDE,无需跳转到其他应用;
提到的源代码旁边有行标,点击一键查看文档;
文档浏览器支持与IDE一致的代码亮度,引用跳转;
线上效果
代码中文档会定期自动部署到远端。以一篇真实业务文档举例,HTML 部署到轻服务后长这样:
对应飞书的产物长如下:
这些线上页面主要面向非当前团队的读者,内容由 CI 定时同步,暂不提供跳转到 IDE 的能力。
技术实现
项目的架构如图所示:
考虑到用户体验部分主要呈现在IDEA(AndroidStudio)中,我们的技术栈选择基于InteliJ。按模块可分为三部分:
基建层
IDEA Plugin
Gradle / Dokka Plugin
通用逻辑(语言实现相关)封装在基建层,仅依赖 IntelliJ Core。相对于 IntelliJ Platform,IntelliJ Core 仅保留语言相关的能力,精简了 codeInsight、UI 组件等代码,被广泛用于 IntelliJ 各大产品中(包括图中的 Kotlin、Dokka 等)。
以下将介绍这三个主要模块。
基建
在整个方案中,基础设施是所有功能的基石,其核心能力是建立代码和文档之间的关联。在这里,我们设计了一套标记语言CodeRef,以满足以下需求:
语法简洁,结构与源代码一一对应;
准确的指向,即必须满足一对一的关系;
支持只保留声明(去除body),提高信噪比;
具有扩展性,便于后续迭代新功能;
Coderef语言并不复杂,采用类似Kotlin/Java的风格,用关键字、字符串、括号构成句子和代码块,代码块中的每个节点都有相应的源代码节点。下图是一个简单的示例,相应的关系用着色文字标记:
注:即使文档内容不改变,一旦图片中的源代码部分发生变化,相应的渲染效果也会实时改变,产生动态绑定效果。那么,如何实现动态绑定呢?大致分为以下三个步骤:
设计语法,编写语言实现;
结合现有能力(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组成的树。示意图如图所示:
值得一提的是,后续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 和文件是实时映射的,为不影响原文件内容,必须在副本环境中进行语法树的增删改。
虽然这部分不难,但繁琐程度最高。一方面,由于需要深入细节,上述KotlinLightPSI不再适用,因此必须分别为Java和Kotlin编写和实现。另一方面,如何保证复制后的代码格式正确也是一个大问题,尤其是元素之间的注释。最后,文本内容的生成在不断的断点和调试周期中形而上学地完成。
到目前为止,基建层的任务——将CodeRef还原为代码段——全部完成。
IDEA Plugin
有了前面的基础,IDEAPlugin主要负责使方案的本地使用体验可用易用。具体来说,插件的功能分为两类:
丰富语言功能的CodeRef;
面向Markdown,改进编辑,阅读体验;
接下来分别从以上角度介绍。
语言优化
对于一门新语言来说,从体验层面来说,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() {
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可选安装),包括一套完整的语言实现和编辑器,预览器。以下是预览的生成过程,如图所示:
分为以下步骤:
利用MarkdownParser将文本分析为多个ASTNode;
利用HtmlGenerator内置的visitor访问每个ASTNode生成HTML文本;
将生成的HTMLDocument设置为内置浏览器(如有),最终呈现在屏幕上;
交代个背景:在本项目启动之初,IDEA 正处于 JavaFX-WebView 到 JCEF 的过渡期(直接导致了 AndroidStudio 4.0 左右的版本没有可用的内置 WebView 实现)。
上述方案总结有以下问题:
兼容性差,部分IDE版看不到预览;
每一次MD变更都会触发完整的generateHtml,如果文档内容复杂度高,就会出现性能瓶颈;
将HTML文本set交给浏览器时,没有diff逻辑,会触发页面reload,这也可能导致性能问题(后来diff能力增加到带JCEF的IDE,但并非所有IDE都内置JCEF);
综合考虑,我们决定不直接使用本地插件,而是基于其创建新语言MarkdownX,最大限度地重用原有能力,增加对Coderef的支持,并根据Swing制作一套类似Recyclerview的机制来提高预览性能。
优化后的方案流程类似:
自制方案有许多优点:
内存占用较低(浏览器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。处理过程如下:
机制分为两大步:
Data层将HTML的body分成几个部分,diff后通知View层更改;
View层将变更的数据设置到List对应的位置,并尽可能重用现有的ViewHolder。该过程可能涉及创建和删除ViewHolder;
目前,我们为文本、图片和代码创建了三种ViewHolder:
文本:使用JTextPane与HTML+CSS一起恢复文本样式;
图片:自定义JComponent缩放,绘制,确保图片居中并完整显示;
代码:基于IDE提供的Editor,进行必要的设置和逻辑简化;
在这里,处理Editor花费了大量精力:
使用原始代码文件作为context创建PsicodeFragment作为内容填充Editor,以确保代码中的原始文件import类别、方法、字段可以正常resolve(这一点非常重要,如果使用MockDocument作为内容,绝大多数代码亮度和跳转不生效);
设置合适的HighlightingFilter,确保不报红(将原文件作为context的代价是当前代码片段的类很可能被认为是类重复,代码结构不一定合法,因此需要禁用报红级别的代码分析);
禁用Intention,设置只读(提高性能,减少干扰);
禁止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 的文档框架,“数据流水线”的思想和极强的可扩展性是其特点。代码转换到文档页面的流程如下:
每个节点都有至少一个 Extension Point,扩展起来非常灵活。
图中几个主要角色列如下:
Env:包含基于KotlinCompiler和IntelliJ-Core扩展的代码分析器(用于输出DocumentModels)、开发者定制的插件等组件;
DocumentModels:对module、package、class、function、fields等元素的抽象,呈树形组织,本质上是一些dataclass;
PageModels:PageCreator以DocumentModels为输入,创建的一系列对象是封装页面,描述页面的结构;
Renderer:用于将PageModels渲染成某种格式的产品(Dokka内置HTML、Markdown等););
从以上内容可以看出,Doka最初的功能只是将代码转换为文档页面,而不是本地支持文档文件的转换(真的没有必要)。但是在我们的场景中,MarkdownX的渲染取决于源代码信息,所以Doka的这部分ka的这部分能力。
通过重写PageCreator,我们将包含Markdownx文档的项目变成这样一个节点树:
MdxDirNode 对应文件夹节点,页面内容是当前文件夹的目录,点击链接可跳转至下一级;
MdxPageNode 对应 MarkdownX 文档内容,包含若干类型的 children 分别代表不同类型的内容片段;
在创建MdxPagenode时,我们使用类似于上述IDEA-plugin的方法,重写一个org.jetbrains.doka.barsers.parserser,修改code-fence的处理,改为调用到基础设施部分生成coderef预览文本的代码,最终获得所需的文档文本。
飞书适配
在获得页面内容后,结合Doka自带的HTMLRenderer,很容易输出可用于部署的HTML产品。但目前的情况是,我们更喜欢将文档收敛到飞行书籍中,这需要为飞行书籍编写另一份定制Renderer。
考虑到自己处理页面的树形结构过于复杂,我们实际上是基于内置的Defaultrender基类来扩展的:
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<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的结构是这样的:
而飞书的结构长这样:
可以看出,差异是巨大的。这部分差异的抹平完全取决于自定义的Feishurenderer,具体做法只能由casebycase介绍,仅限于篇幅不展开,一般思路是对不兼容的节点进行展开或合并,穿插必要的子树遍历。
以下是两个特殊点:图片和链接。
文档链接
写Markdown文档时,往往需要插入链接,指向其他Markdown文档(一般使用相对路径)。这时候就需要想办法把相对路径映射成飞书链接,需要在Render步骤之后进行,因为映射的时候需要知道相应文档的飞书链接是什么。
第一反应必须是对文档进行拓扑排序,并根据依赖关系逐一上传文档。但这需要文档之间没有循环依赖,这显然不能保证(两个文档相互引用相当常见)。幸运的是,飞行文档提供了修改文档的界面,因此我们可以提前创建一批空文档,在获得空文档的链接后更换相对路径。换句话说,文档上传的处理过程是:创建空文档->替换相对路径为相应的文档链接->修改文档内容。
图片
图片可以在Markdown中与文本并列,属于Paragraph的一种。在飞行图书文档结构中,图片属于Gallery,只能独占一行,不能与文本同行。这两种格式在实现中不能完全兼容。目前的初步实现方案是在Paragraph的Group入口处向下DFS,找到所有图片,提出放在文本前面。效果,只能忍受。
顺便说一下,图片也需要上传和替换逻辑,类似于文档链接,不重复。
结语
以上是文档套件的全部内容:基于InteliJ技术栈,我们通过设计新语言、编写IDE插件、Gradle/Dokka插件,形成了完整的文档辅助解决方案,有效建立了文档与代码的关联,大大提高了编写和阅读体验。
未来,我们将为框架引入更实用的改进,包括:
添加图形代码元素选择器,降低语言学习和使用成本;
优化预览渲染效果,对齐WebView;
探索部分框架(Dagger、Retrofit等)的文档自动生成能力。
目前框架还处于内部测试阶段,正在逐步扩大推广范围。方案成熟,功能稳定后,将整体开源方案,为更多用户服务,吸收社区Idea。请期待!