Markwon 嵌套无序列表渲染源码剖析:从 ul.md 测试用例到 BulletListItemSpan
UI组件移动开发【免费下载链接】MarkwonAndroid 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.javamatchInput(ul.md, document)会通过IOUtils.resourceToString(tests/ name, ...)从 classpath 读取夹具内容调用markwon().toMarkdown(...)渲染再用TestSpanMatcher.matches(...)与期望结构逐项比对。整个测试基于 RobolectricRunWith(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);这段断言揭示了三个关键事实每个列表项是一个独立的UN_ORDERED_LISTspan而不是一个 span 包住全部文本嵌套通过 span 递归实现第二层列表项 span 嵌套在第一层 span 内部第三层嵌套在第二层内部形成三层嵌套结构每个 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 到 RenderPropsMarkdown 文本首先由 commonmark-java 解析为 ASTListItem、ListBlock等节点随后 Markwon 的访问器逐节点访问。无序列表层级提取的核心逻辑位于 CorePlugin.java 的listItem访问器private static void listItem(NonNull MarkwonVisitor.Builder builder) { builder.on(ListItem.class, new MarkwonVisitor.NodeVisitorListItem() { 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_TYPEBULLET/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项目符号是怎么画出来的BulletListItemSpanBulletListItemSpan.java实现的是 Android 的LeadingMarginSpan接口通过“左边距 在边距区域内绘制图形”实现列表缩进与项目符号。边距计算getLeadingMargin恒定返回theme.getBlockMargin()默认 24dp见 MarkwonTheme.java也就是说每一层嵌套都贡献一个完整的blockMargin宽度缩进——这正是多级嵌套时左侧缩进逐层加宽的原因。绘制时机drawLeadingMargin中通过LeadingMarginUtils.selfStart(start, text, this)判断当前行是否为该 span 覆盖文本的起始行若非首行或不是 span 自身起点则直接返回避免换行后重复绘制项目符号。符号样式与层级的关系是本文最核心的结论代码在 BulletListItemSpan.java#L113-L126if (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.Style0实心圆FILL1空心圆STROKE≥2实心方块FILLdrawRect因此ul.md中的三级列表在视觉上呈现为第一层实心圆点 → 第二层空心圆 → 第三层小方块层级差异一目了然。绘制定位细节符号的边长side取自theme.getBulletWidth(textLineHeight)默认取min(blockMargin, 行高) / 2垂直位置以baseline为基准、按(descent ascent) / 2对齐行中线。绘制前调用theme.applyListItemStyle(paint)应用主题色。此外代码中还有两处平台兼容处理RTL 支持通过dir方向符计算left/rightRTL 布局下符号会出现在右侧边距Nougat 特判IS_NOUGATAndroid 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.mdol.md三级缩进3 空格的嵌套有序列表断言结构为三层嵌套ORDERED_LISTspan每层start 1——测试注释特别说明“想用 1,2,3 作为起始编号但任何非1的起始值都不会被当作子列表”ol-2-spaces.mdol-2-spaces.md只缩进 2 空格时不再被解析为子列表测试注释引用 CommonMark 0.29 的规则“不能超过 3 空格”而是退化为扁平的独立列表项Second 3. Third甚至被合并进同一段文本ol-starts-with-5.mdol-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组件移动开发【免费下载链接】MarkwonAndroid markdown library (no WebView)项目地址https://gitcode.com/gh_mirrors/ma/Markwon点击查看免费下载相关推荐Markwon 无序列表嵌套层级ul-levels渲染机制深度解析Markwon 无序列表嵌套层级ul levels渲染机制深度解析 本篇技术指南以 MarkwonAndroid Markdown 库无 WebViewUI组件移动开发Markwon 嵌套引用块Nested Blockquotes渲染解析从测试夹具到 BlockQuoteSpan 源码实现Markwon 嵌套引用块Nested Blockquotes渲染解析从测试夹具到 BlockQuoteSpan 源码实现 Markwon 是一个不依赖UI组件移动开发mdBook 列表渲染全解析从有序/无序列表到嵌套与起始序号mdBook 列表渲染全解析从有序/无序列表到嵌套与起始序号 mdBook 使用 Rust 生态成熟的 pulldown cmark 解析器将 Markdow开发工具文档创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考