资讯动态

成为全栈·Flutter App 篇·Flutter Markdown 阅读器:支持范围与渲染边界

发布时间:2026/10/15 16:03:07 来源:尧图企业网站定制
成为全栈·Flutter App 篇·Flutter Markdown 阅读器支持范围与渲染边界做阅读器之前我先问了一个问题App 里渲染的 Markdown和 Web 上渲染的是同一样东西吗想当然是。但写完第一版就发现不对而且不对的地方比想象的多。用户的文章是从 Web 后台写的 Markdown编辑器支持 GFM。Web 端渲染得好好的搬到 App 里却出四类问题代码块在手机上溢出了、表格被裁掉一半、目录点击跳错位置、深色模式下代码颜色几乎看不清。这四类问题的共同点是——它们都不在Markdown 语法的范畴里而在渲染环境的范畴里。这篇讲core/markdown/那三百多行是怎么处理这些的以及一个我一开始完全没意识到、后来变成整个模块最核心的问题目录锚点到底由谁定义。为什么不是同一个东西Web 浏览器里渲染 Markdown本质是把它转成 HTML 字符串交给浏览器。浏览器有完整的 CSS、DOM、以及十六年的排版经验——表格会自动换行、图片会自动缩放、代码块自带横向滚动。Flutter 没有这些东西。它有 Widget而 Markdown 库要做的事是把语法节点翻译成 Widget。差别可以用一个问题来概括在浏览器里一个宽度为 400px 的表格是一句 CSS在 Flutter 里它是一个需要你手动实现的滚动容器。所以 M4-13 这件事的难点不在解析 Markdown那是库的工作而在渲染约束手机屏幕窄、用户可能开大字号、主题会变、内容不可信。不可信的内容是第一约束先说安全因为它决定了后面所有取舍。文章正文来自用户投稿是外部输入。这意味着class_CodeBuilderextendsMarkdownElementBuilder{_CodeBuilder(this.public);finalbool public;...return_CodeBlock(source:source,language:language,public:public);}这个public参数一路传到代码块用来决定这段代码能不能进共享缓存。看CodeCache的实现/// 仅共享公开正文的词法节点不缓存主题颜色、组件或目录定位键。classCodeCache{staticfinal_nodesString,Listhl.Node{};staticint _characters0;staticListhl.Nodeparse(Stringsource,Stringlanguage,{required bool public,}){finalkey$language\u0000$source;finalexistingpublic?_nodes.remove(key):null;if(existing!nullpublic){_nodes[key]existing;// ← 命中后放回队尾实现 LRUreturnexisting;}finalnodeshl.highlight.parse(source,language:language.toLowerCase()).nodes??[];if(publickey.lengthCacheLimits.highlightEntryCharacters){_nodes[key]nodes;_characterskey.length;while(_nodes.lengthCacheLimits.highlightEntries||_charactersCacheLimits.highlightCharacters){finalfirst_nodes.keys.first;_characters-first.length;_nodes.remove(first);}}returnnodes;}三个设计点public决定进不进缓存。私有预览自己看自己的待审核稿件传public: false代码内容完全不过缓存。因为如果进了共享缓存一个用户读过的私有稿件内容可能留在全局内存里被下一个用户的相同代码命中。缓存的是词法节点不是颜色。hl.highlight.parse产出的是这个 token 是关键字、这个是字符串这样的结构不包含具体颜色。颜色在构建 Widget 时按当前主题算。如果直接缓存带颜色的结果切换深浅色主题时旧颜色会被复用——用户切到深色模式代码块还是浅色的。这个 bug 我遇到过表现很怪因为刷新一下又好了。双重容量限制。既限制条目数也限制总字符数while(_nodes.lengthCacheLimits.highlightEntries||_charactersCacheLimits.highlightCharacters){只限制条目数不够——一个超长代码块可能是别的十个条目那么重两个限制一起才能保证内存有界。那行remove再_nodes[key] existing是标准 LRU 实现命中时把 key 移到队尾这样最久没用的自然在队首淘汰时删它。至于原始 HTML——无条件不执行。flutter_markdown默认不解析 raw HTML这是正确的默认值。因为一旦执行了script或iframe就等于在 App 里开了一个不受控的浏览器。目录锚点整个模块最难的地方现在说那个我一开始完全没想到的问题。用户点目录里的接口设计页面应该滚到正文对应的那一行。但客户端怎么知道接口设计这个标题在正文的哪一行我最初的方案很自然客户端自己给标题生成锚点。Web 上就是这么干的——github-slugger把标题文字转成小写连字符比如接口设计 →#接口设计。写完发现点不动。原因有两个都很致命第一客户端不知道服务端用什么算法。M1 定的契约里TocItem是这么定义的TocItem { level, text, anchor }anchor是服务端生成的用什么规则、是否唯一、遇到重名标题怎么处理——契约没有写也没打算写。第二客户端数出来的标题顺序可能和服务端不一样。这个问题更隐蔽也更容易漏。比如正文里有一段代码bash # 这是注释不是标题 echo hello 代码块里那个#开头的行是注释不是标题。但如果客户端的解析逻辑不小心把它当成标题那目录就会多出一项、和后续标题全部错位一位。还有 Setext 风格标题接口设计 它用的是下划线而不是#。有的解析器支持有的不支持——只要客户端和服务端的支持范围不一致目录就全错。所以现在的方案是客户端不再自己猜直接用服务端给的 anchor。/// 仅 ATX 标题消耗服务端目录项代码围栏和 Setext 标题不冒充目录锚点。classServerHeadingSyntaxextendsmd.HeaderSyntax{ServerHeadingSyntax(this.toc);finalListApiTocItemtoc;int cursor0;Object?document;overridemd.Nodeparse(md.BlockParserparser){if(!identical(document,parser.document)){cursor0;// 换了文档游标归零documentparser.document;}finalrawparser.current.content;finalnodesuper.parse(parser)asmd.Element;finalmatchRegExp(r^(#{1,6})\s(.?)\s*#*\s*$).firstMatch(raw);if(match!nullcursortoc.length){finalitemtoc[cursor];if(item.levelmatch[1]!.lengthitem.textmatch[2]!.trim()){node.attributes[reader-anchor]item.anchor!;cursor;}}returnnode;}}这段代码的核心是一个游标对齐算法。逐条解析标题时拿当前的标题和toc[cursor]比检查不一致时level相等吗不消耗目录项游标不动text相等吗同上都相等打上reader-anchorcursor为什么要这么保守因为一旦错位后面全部错位。所以只在层级和文字都完全一致时才认。注意cursor只在匹配成功时执行——这意味着如果客户端多解析出一个标题理论上不该发生游标会一直卡在那一项后面全对不上。这是宁可全错也不局部错的取舍因为局部对局部错的体验更糟用户点前三个能跳点第四个跳到别处。而if (!identical(document, parser.document))这个判断是必需的解析器可能被复用比如列表切换时复用了同一个对象而游标是实例字段。不重置的话第二篇文章的锚点会接着第一篇的游标走全错。顺带说item.anchor!这个非空断言。它是安全的——anchor由服务端保证存在而生成模型把它定义成了可空M4-03 讲过为什么所有字段都可空。所以这里的!是在已知边界条件下收窄类型和 M4-03 里Article.id!是同一个模式。标题渲染key 绑在服务端 anchor 上拿到 anchor 之后标题组件要挂一个GlobalKey供目录点击时定位class_HeadingBuilderextendsMarkdownElementBuilder{finalMapString,GlobalKeykeys;overrideWidget?visitElementAfterWithContext(...){finalanchorelement.attributes[reader-anchor];finalh2element.tagh2;returnContainer(key:anchornull?null:keys[anchor],margin:EdgeInsets.only(top:h2?AppSpacing.s8:AppSpacing.s6,bottom:AppSpacing.s3,),padding:EdgeInsets.only(left:h2?10:0),decoration:h2?BoxDecoration(border:Border(left:BorderSide(color:context.colors.brand,width:3),),):null,child:Text(element.textContent,style:preferredStyle),);}}注意key: anchor null ? null : keys[anchor]——只有成功匹配到服务端 anchor 的标题才挂 key。没匹配上的标题如果真的出现了就是普通文本点目录时不会尝试定位它。宁可让某个标题点不动也不要跳到错误的位置。一个跳错的目录比一个失效的目录糟糕得多——跳错了用户会以为是内容错了。h2的特殊处理左边框 更大间距纯粹是视觉决策用左边框把二级标题和三级标题区分开长文章里层级关系一眼可见。大字号缓存必须跟着失效这是另一个我一开始漏掉的坑而且很难发现。ReaderMarkdown是个StatefulWidget内部缓存了构建好的 bodyclass_ReaderMarkdownStateextendsStateReaderMarkdown{Widget?body;overridevoiddidChangeDependencies(){super.didChangeDependencies();Theme.of(context);MediaQuery.textScalerOf(context);bodynull;// ← 依赖变了缓存作废}overridevoiddidUpdateWidget(covariantReaderMarkdownold){super.didUpdateWidget(old);if(old.content!widget.content||!identical(old.headingKeys,widget.headingKeys)||old.publicImages!widget.publicImages){bodynull;}}为什么必须这样因为Flutter 的 Widget 是不可变的一旦构建完成就固定了。字号变大之后旧 body 里的 Text 还是旧字号——用户调了字体大小但文章看起来没变。didChangeDependencies那一行Theme.of(context);和MediaQuery.textScalerOf(context);看起来是取了值但没用实际上它们的作用是建立依赖。读一次InheritedWidget变化时这个build上下文就会重新执行于是body null触发重建。而didUpdateWidget里的!identical(old.headingKeys, widget.headingKeys)用的是恒等比较而不是内容比较。因为 heading keys 是个 Map内容相同但实例不同时也要重建——key 变了意味着 GlobalKey 的绑定变了不重建会串位。恒等比较比深度比较快得多而且这里语义上要的正是是不是同一个对象。这也解释了 M4-10 里AsyncPane那个loadKey为什么存在——同一个道理Widget 复用但输入变了必须能识别。私有预览要关掉的东西最后回到安全。ReaderMarkdown有个publicImages参数/// 正文渲染依赖内容、目录定位键与主题私有预览禁用公开图片和词法缓存。classReaderMarkdownextendsStatefulWidget{constReaderMarkdown(this.content,{this.tocconst[],this.headingKeysconst{},this.publicImagestrue,});两个开关一起关开关私有预览时的值为什么publicImagesfalse待审核稿件的图片不能进公开图片缓存代码块publicfalse稿件内容不进共享词法缓存两个都是缓存隔离而缓存隔离比权限校验更容易被忽略——因为权限校验是显式的代码而缓存是隐式的副作用。写代码时很容易想这个请求带了 token所以是安全的忘了它的结果还会留在本地。验收该看什么阅读器的测试有个特殊之处大部分视觉问题在单测里看不出来。场景怎么验代码块在窄屏能横向滚动Widget 测试里设一个 320 宽的约束表格不撑破布局同上且要构造一个 5 列宽表目录跳转位置正确集成测试真渲染后检查滚动位置围栏里的#不进目录构造含代码块的正文断言toc匹配数深色模式代码可读必须真机看截图对比度算不出来大字号布局不崩集成测试里把textScaler设成 2.0最后两条要单独说深色模式的代码配色没法用断言验。你没法在测试里写这个颜色对比度要大于 4.5因为主题是高亮库生成的token 到颜色的映射由第三方库决定。只能看。这是我在这个项目里最不愿意承认但必须承认的事——有一类问题只能靠人眼。大字号是最容易崩的场景。因为绝大多数设计是在默认字号下做的。字号放大到 2.0 后标题可能换行、代码块高度暴涨、目录项被挤到两行——全都在正常字号下看不见。一条我一开始想省掉的事ServerHeadingSyntax里那个游标对齐算法我第一版写的是客户端自己 slug 化标题简单得多。切换到服务端 anchor 之后多出来二十行我当时觉得这个复杂度不值得。然后我遇到一个具体场景用户写了两篇标题相同的文章各自内部还有一个同名的小节。比如文章里出现两次总结## 总结 ... ## 实现 ... ## 总结 ← 客户端 slug 化会生成同一个 anchor ...客户端 slug 化会得到两个完全相同的#总结点目录跳到哪个全靠运气。服务端给的 anchor 则保证唯一它知道这是第几个。这就是那个多出来的二十行在解决什么把唯一性的责任交给唯一能保证它的一方。类似的还有item.text match[2]!.trim()这个严格比较。我一度想放宽成包含关系——万一服务端对标题文字做了某种处理比如把**加粗**里的标记去掉客户端解析出来的文字就对不上了。放宽的代价是文本相似但不同的两个标题会被误认于是游标错位后续全部锚点混乱。而严格比较的代价只是这个标题没有 anchor点不了。一个失效的目录项 vs 整个目录错位代价差着好几个数量级。小结Markdown 阅读器这个模块技术上不难但它把三个别的模块没碰过的约束集中到了一起不可信输入—— 原始 HTML 不执行私有内容不进共享缓存。渲染环境—— 窄屏要滚动、大字号要失效缓存、主题要重算颜色。跨端契约—— 锚点由服务端定义客户端只做严格对齐。第二点里最反直觉的是大字号会让缓存失效。直觉上字号是显示层的属性和内容无关但因为 Widget 不可变一旦构建完成字号就固化在组件里了。这意味着内容没变不等于渲染结果可以复用。第三点的教训则是当客户端需要某个唯一标识时先问它是不是自己能生成的。如果不能比如算法未公开就让它来自契约的作者不要自己实现一个近似版本。近似版本在简单情况下能工作在边界情况下会静默出错——而静默出错的目录跳转用户是查不出原因的。下一篇讲服务端目录的具体使用——上下篇、锚点、阅读量和阅读历史它们共享同一份缓存策略但行为各不相同。延伸阅读Dio Repository统一响应信封与模型适配文章详情Markdown、代码高亮与目录必须共享多级分类、标签与 URL让内容导航既可读又可索引如果这篇文章对你有帮助欢迎订阅我的 CSDN 专栏「成为全栈」 专栏地址https://blog.csdn.net/fungleo/category_13204651.html 本系列配套代码仓库https://github.com/fengcms/become-a-full-stack-developer

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑