- 移动开发
- UI组件
【免费下载链接】plaid
An Android app which provides design news & inspiration as well as being an example of implementing material design.
本文以 third_party/bypass/README.md 为核心骨架,深入讲解 Plaid 项目如何 fork 并改造开源 Markdown 处理器 Bypass:通过新增
TouchableUrlSpan、FancyQuoteSpan、ImageLoadingSpan与LoadImageCallback四类扩展,让纯文本 Markdown 渲染具备"按下可反馈的链接"、"带主题色竖线的引用块"和"占位后异步替换的图片"三大能力。读完本文,你将掌握这套扩展的源码级实现原理、配置参数含义,以及它们在 Plaid 中的实际装配方式。
一、背景:为什么 Plaid 要 fork Bypass
Bypass 是一个以 C/C++ 解析器(通过 JNI 加载libbypass.so)配合 AndroidSpannable体系输出富文本的 Markdown 处理器,特点是解析快、不依赖 WebView。Plaid 在设计新闻(Designer News)模块中需要把大量 Markdown 格式的帖子正文渲染进TextView,因此把它以第三方库的形式内嵌到仓库中。
但原生 Bypass 的渲染效果并不完全满足 Plaid 的需求:
- 原生
URLSpan在TextView里对触摸点击不敏感,链接缺少按压反馈; - 默认的
QuoteSpan引用块视觉效果平淡,与 Material Design 风格不协调; - 原生实现无法在图片下载完成前留下占位、下载后原位替换,也就无法和 Glide 等图片加载库协作。
于是 Plaid 在 third_party/bypass/README.md 中明确记录了对上游项目(Uncodin 的 Bypass)所做的四项本地增强,对应的版本、许可信息记录在同目录的 README.google 中:版本 1.1,遵循 Apache License v2.0(完整条款见 LICENSE.txt),并且本地修改点与 README 完全一致——"Added TouchableUrlSpan, FancyQuoteSpan & ImageLoadingSpan + LoadImageCallback mechanism"。
下面逐一剖析这四项扩展的实现与用法。
二、TouchableUrlSpan:让链接拥有按下态反馈
README 中的描述是"An extension to URLSpan which changes its background & foreground color when pressed"——即按下链接时同时改变前景色与背景色。
2.1 实现原理
源码位于 TouchableUrlSpan.java,它继承URLSpan,在构造时从传入的ColorStateList中解析出常态与按下态两种文字颜色:
public TouchableUrlSpan(String url, ColorStateList textColor, int pressedBackgroundColor) { super(url); this.normalTextColor = textColor.getDefaultColor(); this.pressedTextColor = textColor.getColorForState(STATE_PRESSED, normalTextColor); this.pressedBackgroundColor = pressedBackgroundColor; }核心在于重写updateDrawState(TextPaint),把"是否按下"映射为画笔状态:
@Override public void updateDrawState(TextPaint drawState) { drawState.setColor(isPressed ? pressedTextColor : normalTextColor); drawState.bgColor = isPressed ? pressedBackgroundColor : 0; drawState.setUnderlineText(!isPressed); }注意一个细节:常态下链接显示下划线,按下时下划线消失,配合背景色与前景色的变化形成完整的按压反馈。外部通过setPressed(boolean)驱动状态切换。
2.2 与触摸联动:Plaid 的配套改造
单有 Span 还不够,TextView默认的触摸链路不会把按下事件转发给 Span。Plaid 在 HtmlUtils.java 中做了完整的配套:
public static void setTextWithNiceLinks(TextView textView, CharSequence input) { textView.setText(input); textView.setMovementMethod(LinkTouchMovementMethod.getInstance()); textView.setFocusable(false); textView.setClickable(false); textView.setLongClickable(false); }即必须给TextView设置自定义的LinkTouchMovementMethod(LinkTouchMovementMethod.java),把触摸按下/抬起事件转发给TouchableUrlSpan.setPressed(...)。此外,HtmlUtils.java 中linkifyPlainLinks还会把Linkify生成的普通URLSpan统一替换为TouchableUrlSpan,保证纯文本链接(非 Markdown 语法书写的 URL)也具备同样的按压反馈。
三、FancyQuoteSpan:绘制更优雅的引用块
README 中的描述是"A quote span with a nicer presentation"。原生方案使用QuoteSpan的默认竖线样式,Plaid 则自绘一条可定制颜色、宽度和缩进间距的竖线。
3.1 实现原理
源码位于 FancyQuoteSpan.java,它实现LeadingMarginSpan:
public int getLeadingMargin(boolean first) { return lineWidth + gapWidth; } public void drawLeadingMargin(Canvas c, Paint p, int x, int dir, int top, int baseline, int bottom, CharSequence text, int start, int end, boolean first, Layout layout) { Paint.Style prevStyle = p.getStyle(); int prevColor = p.getColor(); p.setStyle(Paint.Style.FILL); p.setColor(lineColor); c.drawRect(x, top, x + dir * lineWidth, bottom, p); p.setStyle(prevStyle); p.setColor(prevColor); }getLeadingMargin返回lineWidth + gapWidth,为引用文本腾出"竖线 + 间隔"的左边距;drawLeadingMargin在行首绘制一条从top到bottom的实心矩形竖线,绘制结束后恢复画笔原有的样式与颜色,避免污染后续文本绘制。
三个构造参数分别为竖线宽度(quoteLineWidth)、竖线与文本的间距(quoteLineIndent)、竖线颜色(quoteLineColor)。
3.2 在 Bypass 中的装配
在 Bypass.java 的BLOCK_QUOTE分支中,原生QuoteSpan被注释掉,替换为两层边距 +FancyQuoteSpan+ 前景色 + 斜体:
case BLOCK_QUOTE: setBlockSpan(builder, new LeadingMarginSpan.Standard(mBlockQuoteIndent)); setBlockSpan(builder, new FancyQuoteSpan(mBlockQuoteLineWidth, mBlockQuoteLineIndent, mOptions.mBlockQuoteLineColor)); setBlockSpan(builder, new ForegroundColorSpan(mOptions.mBlockQuoteTextColor)); setBlockSpan(builder, new LeadingMarginSpan.Standard(mBlockQuoteIndent)); setBlockSpan(builder, new StyleSpan(Typeface.ITALIC)); break;代码注释说明了为什么加两层LeadingMarginSpan.Standard:"We add two leading margin spans so that when the order is reversed, the QuoteSpan will always be in the same spot."——即保证 Span 顺序反转后竖线仍固定在正确位置。整个块引用还叠加了引用文本前景色与斜体,形成层次分明的排版。
四、ImageLoadingSpan 与 LoadImageCallback:异步图片的占位与替换机制
这是四项扩展中最能体现工程价值的一组。README 的描述是:ImageLoadingSpan是一个"用来标记文本中某处将被下载完成的图片替换"的简单 Span;LoadImageCallback则是图片加载的回调机制。
4.1 ImageLoadingSpan:纯占位标记
源码位于 ImageLoadingSpan.java,它继承CharacterStyle,updateDrawState为空实现——它本身不绘制任何内容,只作为"图片应插入于此"的锚点:
public class ImageLoadingSpan extends CharacterStyle { @Override public void updateDrawState(TextPaint textPaint) { // no-op } }4.2 LoadImageCallback:异步加载的接口
接口定义在 LoadImageCallback.kt:
interface LoadImageCallback { fun loadImage(src: String, loadingSpan: ImageLoadingSpan) }调用方(如 Glide 封装层)拿到图片 URL 与占位 Span 后自行发起异步加载,加载完成后用真实图片替换loadingSpan所覆盖的文本区间。
4.3 Bypass 中的完整渲染流程
在 Bypass.java 的IMAGE分支中可以看到完整的占位逻辑:
case IMAGE: String url = element.getAttribute("link"); if (loadImageCallback != null && !TextUtils.isEmpty(url)) { setPrependedNewlineSpan(builder, mOptions.mPreImageLinebreakHeight); ImageLoadingSpan loadingSpan = new ImageLoadingSpan(); setSpanWithPrependedNewline(builder, loadingSpan); // make the (eventually loaded) image span clickable to open in browser setSpanWithPrependedNewline(builder, new TouchableUrlSpan(url, linksColors, highlightColor)); loadImageCallback.loadImage(url, loadingSpan); } break;几个值得注意的实现细节:
- 图片独占一行:文本构建阶段在图片前先插入换行(
builder.append("\n")),保证图片总是另起一行展示; - 无回调时输出替换字符:如果未提供
LoadImageCallback或图片 URL 为空,则只插入\uFFFC(对象替换字符),图片无法加载时界面不会崩溃; - alt/title 兜底:存在 alt 文本时优先输出
[alt]作为占位文字,否则输出\uFFFC; - 图片同时可点击:图片位置同时挂一个
TouchableUrlSpan,加载完成后点按图片可在浏览器中打开原图; - 换行高度可配置:
setPrependedNewlineSpan用mPreImageLinebreakHeight控制图片前空白行的高度(默认 4dp 级别,见下文 Options)。
五、配套的 Options 配置体系
这四个扩展的视觉效果大多由 Bypass.java 内嵌的Options类驱动。Options提供链式 setter,默认值如下:
| 配置项 | setter 方法 | 默认值 | 说明 |
|---|---|---|---|
| 标题字号倍数(h1~h6) | setHeaderSizes(float[6]) | {1.5f, 1.4f, 1.3f, 1.2f, 1.1f, 1.0f} | 必须恰好 6 个元素,否则抛IllegalArgumentException |
| 无序列表符号 | setUnorderedListItem(String) | "\u2022"(•) | 渲染无序列表项的前缀字符 |
| 列表项缩进 | setListItemIndentSize(unit, size) | dp, 10 | 单位可为TypedValue.COMPLEX_UNIT_DIP/SP/PX等 |
| 引用竖线颜色 | setBlockQuoteLineColor(int) | 0xff0000ff(蓝) | FancyQuoteSpan竖线颜色 |
| 引用文本颜色 | setBlockQuoteTextColor(int) | 无(默认黑色) | 引用块前景色 |
| 引用竖线宽度 | setBlockQuoteLineWidth(int) | 未设默认(由调用方传入) | 单位固定为 dp |
| 引用竖线间距 | setBlockQuoteLineIndent(int) | 未设默认(由调用方传入) | 竖线与文本间隔,单位 dp |
| 引用整体缩进 | setBlockQuoteIndentSize(unit, size) | dp, 10 | 两层边距之一 |
| 图片前换行高度 | setPreImageLinebreakHeight(int) | 4 | 控制图片前空白行像素高度 |
| 代码块缩进 | setCodeBlockIndentSize(unit, size) | dp, 10 | BLOCK_CODE左缩进 |
| 分割线颜色/尺寸 | setHruleColor / setHruleSize | Color.GRAY;dp, 1 | 供HorizontalLineSpan绘制---分割线 |
构造函数Bypass(DisplayMetrics displayMetrics, Options options)会把这些配置统一通过TypedValue.applyDimension换算成像素,换算结果缓存为成员变量,供渲染时直接使用。
除上述四个扩展外,仓库还包含一个同样由 Plaid 定制的 HorizontalLineSpan.java:继承ReplacementSpan,getSize返回Integer.MAX_VALUE以横贯整行,绘制时在行垂直中段画一条矩形分割线,对应 Markdown 的---语法。
六、在 Plaid 应用中的装配方式
Bypass 在 Plaid 中通过 Dagger 注入为Markdown接口实例。装配点位于 MarkdownModule.kt:
@Module class MarkdownModule constructor( private val displayMetrics: DisplayMetrics, private val options: Bypass.Options = Bypass.Options() ) { @Provides @FeatureScope fun provideMarkdown(): Markdown = Bypass(displayMetrics, options) }Markdown接口定义在 Markdown.kt,它把渲染入口统一为:
fun markdownToSpannable( content: String, linksColor: ColorStateList, @ColorInt highlightColor: Int, callback: LoadImageCallback? ): CharSequence而在 HtmlUtils.java 中,parseMarkdownAndPlainLinks把 Markdown 渲染与纯文本链接识别结合起来:
public static CharSequence parseMarkdownAndPlainLinks( String input, Markdown markdown, ColorStateList linkTextColors, @ColorInt int highlightColor, LoadImageCallback loadImageCallback) { CharSequence markedUp = markdown.markdownToSpannable(input, linkTextColors, highlightColor, loadImageCallback); return linkifyPlainLinks(markedUp, linkTextColors, highlightColor); }其方法注释解释了这样组合的原因:Markdown不会处理非 Markdown 语法的裸链接,而Linkify处理裸链接时会抹掉已有 Span,因此只能"先用 Markdown 渲染,再对输出副本做 Linkify,最后把新发现的URLSpan换成TouchableUrlSpan合并回结果"——两全其美。这也正是 README 中四项扩展被设计出来的真实工程动机。
七、构建与原生库依赖
Bypass 的 Markdown 解析由原生代码完成。在 Bypass.java 的静态块中:
static { System.loadLibrary("bypass"); }对应地,仓库在 third_party/bypass/src/main/jniLibs 下为arm64-v8a、armeabi、armeabi-v7a、mips、mips64、x86、x86_64七种 ABI 预编译了libbypass.so,解析入口processMarkdown声明为private native Document processMarkdown(String markdown)(Bypass.java),将 Markdown 文本解析为 Document.java 与 Element.java 组成的元素树,再由 Java 侧递归遍历(recurseElement)逐节点挂载 Span。
解析树渲染的典型元素类型包括:HEADER(按 level 应用RelativeSizeSpan+ 粗体)、LIST/LIST_ITEM(有序号则从 1 递增,无序则用mUnorderedListItem)、EMPHASIS/DOUBLE_EMPHASIS/TRIPLE_EMPHASIS(斜体/粗体/粗斜体)、CODE_SPAN/BLOCK_CODE(monospace 字体 + 缩进)、LINK/AUTOLINK(TouchableUrlSpan,邮箱自动加mailto:前缀)、BLOCK_QUOTE(FancyQuoteSpan组合)、STRIKETHROUGH(StrikethroughSpan)、HRULE(HorizontalLineSpan)、IMAGE(占位 + 回调)。此外 ReverseSpannableStringBuilder.java 作为内部构建器,配合setSpanWithPrependedNewline等工具方法保证嵌套元素的 Span 边界正确。
八、小结
Plaid 对 Bypass 的 fork 改动虽小,却精准解决了 Android 富文本渲染的三个常见痛点:
- 链接交互:
TouchableUrlSpan+LinkTouchMovementMethod补上了原生URLSpan缺失的按压反馈; - 引用样式:
FancyQuoteSpan用可配置的竖线、间距与颜色替代默认QuoteSpan,配合斜体与前景色形成 Material 风格引用块; - 图片异步化:
ImageLoadingSpan占位 +LoadImageCallback回调,让 Markdown 渲染与图片加载库解耦协作,且图片始终独占一行、加载后可点击。
整套机制通过Options暴露了从标题字号、列表符号、引用配色到图片占位高度的完整定制面,并由 MarkdownModule.kt 以 Dagger 单例注入到 Plaid 的各业务模块中。如果你要在自己的 Android 项目里接入 Bypass 并复刻这套增强,只需要:内嵌该 fork(含jniLibs各 ABI 的libbypass.so)、按需构造Bypass.Options、实现LoadImageCallback接入 Glide 等图片库,并为承载富文本的TextView设置LinkTouchMovementMethod即可获得与 Plaid 一致的渲染效果。
- 移动开发
- UI组件
【免费下载链接】plaid
An Android app which provides design news & inspiration as well as being an example of implementing material design.
相关推荐
RailsCasts评论功能开发指南:嵌套评论与实时交互的Ruby实现
RailsCasts评论功能开发指南:嵌套评论与实时交互的Ruby实现 RailsCasts是一个经典的Ruby on Rails教程项目,其评论系统实现了嵌套
MarkdownView:一个用于Android的可自定义的Markdown渲染器
MarkdownView:一个用于Android的可自定义的Markdown渲染器 MarkdownView是一个开源的Android库,它允许您在应用程序中显
移动开发UI库/组件Mailing邮件列表管理:如何创建、订阅和退订功能的完整实现
Mailing邮件列表管理:如何创建、订阅和退订功能的完整实现 Mailing是一个基于React的邮件构建与管理工具,能够帮助开发者轻松实现邮件列表的创建、订
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考