☰
Markwon 嵌套无序列表渲染源码剖析:从 ul.md 测试用例到 BulletListItemSpan
2026/10/14 18:27:34 网站建设 项目流程
  • UI组件
  • 移动开发

【免费下载链接】Markwon

Android markdown library (no WebView)

项目地址:https://gitcode.com/gh_mirrors/ma/Markwon
点击查看免费下载

导读

本文以 Markwon 核心模块的测试夹具 ul.md 为切入点,深入剖析无序列表(bullet list)在 Markwon 中从 Markdown 源码到 AndroidSpanned的完整渲染链路:测试断言定义了解析期望,CorePlugin的访问器负责提取列表层级,ListItemSpanFactory负责生成BulletListItemSpan,而后者最终以LeadingMarginSpan的形式在TextView上绘制出项目符号。读完本文,你将理解嵌套无序列表的层级如何被解析、level参数如何影响符号样式(实心圆、空心圆、方块),以及如何通过主题定制列表外观。

关联文档定位:一个只有三行的测试夹具

关联文档 ul.md 全文只有三行:

* First * Second * Third

这是一段标准的、逐级缩进两个空格的三层嵌套无序列表,属于 markwon-core/src/test/resources/tests 目录下 15 个 Markdown 测试夹具之一。在同目录中还存放着ol.md(嵌套有序列表)、ul-levels.md(标记式嵌套)、nested-blockquotes.md、deeply-nested.md等夹具,共同构成 Markwon 核心渲染的回归测试集。

这段夹具本身没有文档说明,它的“技术含义”全部由对应的单元测试承载:在 UnOrderedListTest.java 的test()方法中,它被作为输入喂给Markwon,并断言产出的Spanned结构必须与预期的嵌套 span 完全一致。因此,理解这份文档就等于理解 Markwon 对嵌套无序列表的解析与渲染契约。

测试读取机制位于基类 BaseSuiteTest.java:matchInput("ul.md", document)会通过IOUtils.resourceToString("tests/" + name, ...)从 classpath 读取夹具内容,调用markwon().toMarkdown(...)渲染,再用TestSpanMatcher.matches(...)与期望结构逐项比对。整个测试基于 Robolectric(@RunWith(RobolectricTestRunner.class)、@Config(manifest = Config.NONE)),无需真机即可在 JVM 上运行。

解析期望:嵌套列表最终长什么样

UnOrderedListTest.test() 用TestSpanDSL 描述了ul.md渲染后的期望结构:

final Document document = document( span(UN_ORDERED_LIST, args("level", 0), text("First\n"), span(UN_ORDERED_LIST, args("level", 1), text("Second\n"), span(UN_ORDERED_LIST, args("level", 2), text("Third")))) ); matchInput("ul.md", document);

这段断言揭示了三个关键事实:

  1. 每个列表项是一个独立的UN_ORDERED_LISTspan,而不是一个 span 包住全部文本;
  2. 嵌套通过 span 递归实现:第二层列表项 span 嵌套在第一层 span 内部,第三层嵌套在第二层内部,形成三层嵌套结构;
  3. 每个 span 都携带level参数,取值分别为 0、1、2,与缩进层级一一对应(0 表示最外层);层级由解析阶段根据缩进深度计算得出,最终传给 span 用于绘制。

注意文本内容的组织方式:text("First\n")说明换行符也被保留在列表项 span 的文本范围内,只有最后一个列表项text("Third")末尾没有换行。这保证了三层文本在视觉上依然是一段连续的、带层级缩进的文本流,靠LeadingMarginSpan的边距实现缩进而非硬编码空格。

缩进嵌套与标记嵌套:ul.md 与 ul-levels.md 的差异

在 UnOrderedListTest.levels() 中,另一个夹具 ul-levels.md 展示了标记式嵌套写法:

* First * * Second * * * Third

它与ul.md的缩进式写法产生完全不同的 span 结构:

// ul-levels.md 的期望结构(节选) span(UN_ORDERED_LIST, args("level", 0), text("First")), text("\n"), span(UN_ORDERED_LIST, args("level", 0), span(UN_ORDERED_LIST, args("level", 1), text("Second"))), text("\n"), span(UN_ORDERED_LIST, args("level", 0), span(UN_ORDERED_LIST, args("level", 1), span(UN_ORDERED_LIST, args("level", 2), text("Third"))))

两者差异值得注意:

  • 缩进式(ul.md):三个列表项是“同源嵌套”,First的 span 直接包裹Second、Third的 span,最外层 span 从头贯穿到尾;
  • 标记式(ul-levels.md):每一行都是独立的最外层列表项(level == 0),额外的*标记在内部再次开启新一层的列表 span,因此行与行之间以裸text("\n")分隔,level仅体现在内部嵌套深度上。

从视觉效果看两者都能渲染出三级嵌套列表,但 AST 结构截然不同。这提醒使用者:缩进与前置标记是两种不同的嵌套表达方式,解析结果不同,若自定义 span 遍历逻辑时需区分场景。

渲染管线:从 ListItem AST 到 RenderProps

Markdown 文本首先由 commonmark-java 解析为 AST(ListItem、ListBlock等节点),随后 Markwon 的访问器逐节点访问。无序列表层级提取的核心逻辑位于 CorePlugin.java 的listItem访问器:

private static void listItem(@NonNull MarkwonVisitor.Builder builder) { builder.on(ListItem.class, new MarkwonVisitor.NodeVisitor<ListItem>() { @Override public void visit(@NonNull MarkwonVisitor visitor, @NonNull ListItem listItem) { final int length = visitor.length(); // 先访问子节点再设置 render props: // 嵌套的子列表项会覆盖外层 props,因此必须先 visit 子节点 visitor.visitChildren(listItem); final Node parent = listItem.getParent(); if (parent instanceof OrderedList) { final int start = ((OrderedList) parent).getStartNumber(); CoreProps.LIST_ITEM_TYPE.set(visitor.renderProps(), CoreProps.ListItemType.ORDERED); CoreProps.ORDERED_LIST_ITEM_NUMBER.set(visitor.renderProps(), start); // 访问完子节点后递增起始编号 ((OrderedList) parent).setStartNumber(((OrderedList) parent).getStartNumber() + 1); } else { CoreProps.LIST_ITEM_TYPE.set(visitor.renderProps(), CoreProps.ListItemType.BULLET); CoreProps.BULLET_LIST_ITEM_LEVEL.set(visitor.renderProps(), listLevel(listItem)); } visitor.setSpansForNodeOptional(listItem, length); } }); }

其中listLevel通过向上遍历父节点链、统计遇到的ListItem祖先个数来计算层级:

private static int listLevel(@NonNull Node node) { int level = 0; Node parent = node.getParent(); while (parent != null) { if (parent instanceof ListItem) { level += 1; } parent = parent.getParent(); } return level; }

这里有两个值得注意的实现细节:

  • 先visitChildren再设置 props:注释明确说明“嵌套的子列表项也会是 ListItem,会覆盖我们的 props”,因此必须先把子节点访问完,再为当前节点设置渲染属性,否则嵌套层级的 props 会互相污染;
  • ListItemType枚举定义于 CoreProps.java,配合三个核心属性使用:LIST_ITEM_TYPE(BULLET/ORDERED)、BULLET_LIST_ITEM_LEVEL(无序列表层级)、ORDERED_LIST_ITEM_NUMBER(有序列表编号)。

随后,ListItemSpanFactory.java 从RenderProps中取出这些属性并完成 span 分发:BULLET 类型创建BulletListItemSpan(theme, level);ORDERED 类型则拼接number + "." + '\u00a0'创建OrderedListItemSpan。该工厂在 CorePlugin.configureSpansFactory 中注册为ListItem.class的默认工厂。

BulletListItemSpan:项目符号是怎么画出来的

BulletListItemSpan(BulletListItemSpan.java)实现的是 Android 的LeadingMarginSpan接口,通过“左边距 + 在边距区域内绘制图形”实现列表缩进与项目符号。

边距计算:getLeadingMargin恒定返回theme.getBlockMargin()(默认 24dp,见 MarkwonTheme.java),也就是说每一层嵌套都贡献一个完整的blockMargin宽度缩进——这正是多级嵌套时左侧缩进逐层加宽的原因。

绘制时机:drawLeadingMargin中通过LeadingMarginUtils.selfStart(start, text, this)判断当前行是否为该 span 覆盖文本的起始行,若非首行或不是 span 自身起点则直接返回,避免换行后重复绘制项目符号。

符号样式与层级的关系是本文最核心的结论,代码在 BulletListItemSpan.java#L113-L126:

if (level == 0 || level == 1) { circle.set(l, t, r, b); final Paint.Style style = level == 0 ? Paint.Style.FILL // 第 0 层:实心圆 : Paint.Style.STROKE; // 第 1 层:空心圆 paint.setStyle(style); c.drawOval(circle, paint); } else { rectangle.set(l, t, r, b); // 第 2 层及以上:方块 c.drawRect(rectangle, paint); }

即:

level绘制图形Paint.Style
0实心圆FILL
1空心圆STROKE
≥2实心方块FILL(drawRect)

因此ul.md中的三级列表在视觉上呈现为:第一层实心圆点 → 第二层空心圆 → 第三层小方块,层级差异一目了然。

绘制定位细节:符号的边长side取自theme.getBulletWidth(textLineHeight),默认取min(blockMargin, 行高) / 2;垂直位置以baseline为基准、按(descent + ascent) / 2对齐行中线。绘制前调用theme.applyListItemStyle(paint)应用主题色。此外,代码中还有两处平台兼容处理:

  • RTL 支持:通过dir方向符计算left/right,RTL 布局下符号会出现在右侧边距;
  • Nougat 特判(IS_NOUGAT):Android 7.0 上LeadingMarginSpan收到的x可能与实际位置不符(文本却正确),因此代码手动计算期望位置与实际位置的diff并修正,注释明确写道“如果一切正常,这个diff会是 0”。

主题定制:调整无序列表的外观

无序列表的项目符号尺寸与颜色由 MarkwonTheme 控制,相关字段如下(均为 protected,可通过继承MarkwonTheme并重写 getter 定制):

字段默认值作用
blockMargindip.toPx(24)每一层列表的缩进宽度,也是项目符号的绘制区域宽度
bulletListItemStrokeWidthdip.toPx(1)空心圆(level 1)的描边宽度
bulletWidthmin(blockMargin, 行高) / 2项目符号边长,theme.getBulletWidth(textLineHeight)动态计算
listItemColor文本主色项目符号与有序列表编号的颜色,经applyListItemStyle(paint)生效

自定义主题的典型做法是继承MarkwonTheme覆写对应 getter,再通过Markwon.builder(...).theme(theme).build()注入;若只是想调整缩进,可参考codeBlockMargin、blockMargin等默认值均在MarkwonTheme.createDefault(Context)中集中配置。

与有序列表的分野:同一测试体系下的对比

有序列表走的是另一条分支,但共享同一套测试与属性机制,理解它可以反衬无序列表的设计:

  • ol.md(ol.md):三级缩进(3 空格)的嵌套有序列表,断言结构为三层嵌套ORDERED_LISTspan,每层start = 1——测试注释特别说明“想用 1,2,3 作为起始编号,但任何非1的起始值都不会被当作子列表”;
  • ol-2-spaces.md(ol-2-spaces.md):只缩进 2 空格时不再被解析为子列表(测试注释引用 CommonMark 0.29 的规则“不能超过 3 空格”),而是退化为扁平的独立列表项,Second 3. Third甚至被合并进同一段文本;
  • ol-starts-with-5.md(ol-starts-with-5.md):列表从 5 开始编号,断言每个列表项 span 的start分别为 5、6、7,验证起始编号被正确保留并递增。

上述断言分别位于 OrderedListTest.java 的nested()、two_spaces()、starts_with_5()方法中。有序列表编号在渲染期通过访问器中的setStartNumber(getStartNumber() + 1)实现递增,而OrderedListItemSpan还会在CorePlugin.beforeSetText中通过OrderedListItemSpan.measure(textView, markdown)预测量编号宽度,避免编号过长导致排版抖动。

如何运行与验证这些测试

ul.md及同目录夹具全部由 Robolectric 单元测试驱动,无需模拟器。在仓库根目录执行:

./gradlew :markwon-core:test

即可运行 markwon-core 模块全部测试(UnOrderedListTest、OrderedListTest、BlockquoteTest、DeeplyNestedTest等都在io.noties.markwon.core.suite包下)。如果想新增自定义用例,可遵循同一模式:在 markwon-core/src/test/resources/tests 添加夹具文件,在suite包下编写继承BaseSuiteTest的测试类,用matchInput("你的文件.md", document)声明期望的 span 结构即可。

小结

一个只有三行的 ul.md 夹具,背后是一整条完整且严谨的无序列表渲染链路:commonmark AST →CorePlugin访问器提取level→ListItemSpanFactory分发 →BulletListItemSpan按层级绘制实心圆/空心圆/方块。理解这条链路,既能帮你预判 Markwon 对不同嵌套写法的渲染结果(缩进式 vs 标记式),也能让你精准地通过MarkwonTheme定制列表外观,还能为扩展自定义列表行为(如任务列表插件、自定义项目符号)提供清晰的接入点。

  • UI组件
  • 移动开发

【免费下载链接】Markwon

Android markdown library (no WebView)

项目地址:https://gitcode.com/gh_mirrors/ma/Markwon
点击查看免费下载

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询