先把结论放在前面:富文本的本质是"在同一段文本上叠加一组区间(Span),让每个区间携带自己的样式或行为"。 面试的考点集中在四处:Span 的接口选择、MovementMethod 与 ClickSpan 的配合、性能(何时该预构建)、以及 Compose 的等价实现。 题目看起来小,但追问密度高,而且答不出默认行为会立刻露馅。
这题的特点是:用过一次就能答对 80%,剩下 20% 全在细节(SPAN_INCLUSIVE_EXCLUSIVE 的选择、点击热区、复用 Spannable 的线程安全)。能把 20% 讲出来,就明显区别于只用过的人。
Span 的四类接口与选择依据
| 接口 | 代表类 | 作用 | 关键约束 |
|---|---|---|---|
CharacterStyle |
ForegroundColorSpan、StyleSpan、UnderlineSpan |
只改样式,不影响范围 | 与 TextAppearanceSpan 都会叠加 |
ParagraphStyle |
BulletSpan、QuoteSpan、LineHeightSpan |
作用于整段(行、段落) | 只在段落边界生效 |
MetricAffectingSpan |
RelativeSizeSpan、ScaleXSpan |
影响测量 | 会在测量阶段反复调用,必须轻量 |
ClickableSpan |
自定义 | 行为(点击跳转) | 需要 MovementMethod 配合 |
// 四类一起用,区间按需选择
val sp = SpannableString("本文有 3 个高频考点:Span、MovementMethod、复用。")
// 样式类:区间内变色加粗
sp.setSpan(StyleSpan(Typeface.BOLD), 0, sp.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
sp.setSpan(ForegroundColorSpan(colorAccent), 5, 9, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
// 行为类:可点击跳转
sp.setSpan(object : ClickableSpan() {
override fun onClick(widget: View) {
val url = (widget.text as Spanned).getSpanStart(this)
openDetail(widget.context, tag)
}
override fun updateDrawState(ds: TextPaint) {
ds.color = colorAccent
ds.isUnderlineText = true // 可点击才画下划线,纯装饰不该有
}
}, 10, 14, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
binding.text.text = sp
binding.text.movementMethod = LinkMovementMethod.getInstance() // 缺这行点击不生效
这段代码里最容易漏的就是 movementMethod。 ClickableSpan 只负责响应点击,但真正把触摸分发到 Span 上的是 LinkMovementMethod(它内部重写 onTouchEvent,根据 getOffsetForPosition 找到命中的 Span)。不设 movementMethod,ClickableSpan 形同虚设。
区间标记四个标志的选择
SPAN_EXCLUSIVE_EXCLUSIVE / INCLUSIVE_EXCLUSIVE / EXCLUSIVE_INCLUSIVE / INCLUSIVE_INCLUSIVE——第一个标记"文本在区间之前插入时是否继承",第二个标记"之后插入时是否继承"。实际开发中绝大多数场景用 EXCLUSIVE_EXCLUSIVE,因为富文本的插入操作应该明确落在区间内或区间外,而不是隐式继承。
值得讲清的例外是编辑场景:正在输入的文本光标位于高亮区间内时,必须用 INCLUSIVE 标志,否则用户在区间内插入字符会丢掉高亮。这个细节是"用过编辑器"的标志。
性能:预构建与复用
// 反模式:每次绑定都重建 Span 对象
override fun onBindViewHolder(h: VH, pos: Int) {
val sp = SpannableString(data[pos].raw) // 每次分配 + 每次 setSpan
sp.setSpan(ForegroundColorSpan(RED), 0, 3, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
h.text.text = sp
}
// 规范:内容不变时复用 Spannable,只在内容变化时重建
private var cachedRaw: CharSequence? = null
private var cachedSpannable: Spannable? = null
private fun spannableFor(raw: CharSequence): Spannable {
if (raw !== cachedRaw || cachedSpannable == null) {
val sp = SpannableString(raw)
sp.setSpan(ForegroundColorSpan(RED), 0, 3, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
cachedRaw = raw
cachedSpannable = sp
}
return cachedSpannable!!
}
关于 MetricAffectingSpan 要单独说:RelativeSizeSpan 这类 Span 会在每次测量时被调用 updateMeasureState,如果在其中做复杂计算,长列表滚动就会掉帧。规范是MetricAffectingSpan 的实现保持极简,不做对象分配与 IO。
还有一个坑是线程安全:Spannable 不是线程安全的,多线程同时修改会抛异常。规范是Span 的构建与修改只在主线程做,后台只准备原始数据。
Compose 侧的等价实现
// AnnotatedString:Span 的 Compose 等价物
val text = buildAnnotatedString {
append("本文有 ")
withStyle(SpanStyle(color = MaterialTheme.colorScheme.primary)) {
append("3 个高频考点")
}
append(":Span、MovementMethod、复用。")
withLink(LinkAnnotation.Url("https://example.com/doc")) {
append("查看文档")
}
}
Text(text) {
// 新 API:把点击处理交给外层,而不是在 Span 里
if (it.hasLink(LinkAnnotation.Url::class)) {
it.getLink(LinkAnnotation.Url::class)!!.url
} else null
}
Compose 侧有两个重要差异:① ClickableSpan 里的 onClick(widget) 拿不到 View,改用 withLink + Text 的 lambda 回调;② 样式与行为是分开的通道(SpanStyle 管样式、LinkAnnotation 管行为),比 View 侧把两者塞进同一个 Span 类的设计更清晰。能指出这个设计差异,说明不是简单翻译。
最常见的坑是
第一层坑:长文本每帧构建 Span 数组,滑动卡顿。
根因是 MetricAffectingSpan 与频繁的对象分配。规范是内容不变时复用 Spannable,MetricAffectingSpan 实现极简。
第二层坑:ClickSpan 区域与文本重绘范围不一致,点击热区偏移。
根因是热区由 LinkMovementMethod 通过 getOffsetForPosition 按行计算,当一行里有多段不同 span 时,热区可能落在错误的行;另外如果 TextView 开了 ellipsize 或 span 跨越换行,坐标映射会偏。规范是复杂富文本改用 Layout 手算 getPrimaryHorizontal 定位,或干脆改用多 TextView 分段渲染。
第三层坑:忘了设 movementMethod,或与 Linkify 自动链接同时开启导致行为冲突。
两者同时存在时,Linkify 生成的 URLSpan 与手写的 ClickableSpan 会在同一段文本上竞争点击。规范是Linkify 与手写 Span 择一,或明确划分各自负责的区间。
这题在面试里怎么问、怎么答
问:Span 有哪几类?ClickableSpan 点击为什么需要 MovementMethod?
答:四类——CharacterStyle(样式)、ParagraphStyle(段落)、MetricAffectingSpan(影响测量)、ClickableSpan(行为)。ClickableSpan 只提供点击回调,真正把触摸分发到 Span 的是 LinkMovementMethod:它重写 onTouchEvent,用 getOffsetForPosition 找到命中的 Span 再回调。不设 movementMethod,ClickableSpan 不会被触发。
问:四个区间标志怎么选?
答:第一个标志控制"在区间之前插入是否继承",第二个控制"之后插入是否继承"。常规展示用 EXCLUSIVE_EXCLUSIVE;编辑场景(光标在高亮区间内输入)必须用 INCLUSIVE,否则插入字符会丢高亮。这个例外是区分"用过"与"做过编辑器"的分界。
问:富文本性能怎么优化?
答:三条。① 内容不变时复用 Spannable,避免每次绑定都重建与 setSpan。② MetricAffectingSpan 保持极简,它在测量阶段被反复调用,复杂实现会直接掉帧。③ 超长富文本考虑分段渲染或裁剪可见区间,不要给整屏一次挂上几百个 Span。能补一句"Spannable 非线程安全,构建只在主线程",说明踩过并发问题。
问:Compose 里对应的 API 是什么?
答:buildAnnotatedString + withStyle(SpanStyle) 管样式,withLink(LinkAnnotation) 管行为,样式与行为是两条独立通道。点击通过 Text(text) { offset -> ... } 的 lambda 接收,与 View 侧 ClickableSpan 拿 View 的方式不同。能指出"行为从 Span 里移到了外层回调"这个设计变化,说明理解到位。
问:点击热区怎么算?为什么会偏移?
答:LinkMovementMethod 用 Layout.getOffsetForHorizontal 与 getPrimaryHorizontal 做行与列的映射,再据此找命中的 Span。偏移的常见原因是:span 跨越换行、TextView 开了 ellipsize、以及一行内多段 span 时行列映射偏差。复杂场景应手写 Layout 定位或改用分段渲染。
落到项目里怎么做
一条能写进规范的红线:所有富文本渲染必须走统一封装(RichTextView 或 Compose 的 RichText 组件),把 Span 构建、复用、movementMethod 设置三件事收口,业务侧不直接操作 Spannable。 这一条能消掉"漏设 movementMethod"与"每次重建"两类高频问题。
配套实践三条:① 在长列表里对富文本 item 做一次滚动性能验证,重点看 MetricAffectingSpan 是否被误用;② 给 ClickableSpan 的点击区域做可视化调试(临时加高亮),确认热区与视觉位置一致;③ 编辑场景明确约定 INCLUSIVE 标志的使用范围,并覆盖"光标在高亮区内输入"这条用例。
给正在准备面试的你
这题的答法要落到"富文本是区间叠加样式与行为,考点在接口选择、MovementMethod 配合、性能与 Compose 差异四处"这条主线。推荐这条线:先给四类接口表 → 讲 ClickableSpan 与 LinkMovementMethod 的分工 → 讲四个区间标志与编辑场景的 INCLUSIVE 例外 → 讲性能三条(复用、Span 极简、非线程安全)→ 讲 Compose 的双通道设计 → 补三个坑 → 收在"统一封装"。能被追问"为什么需要 MovementMethod"并答出"触摸分发由它按 offset 定位 Span",说明理解机制而非背 API;能主动补出"编辑场景要用 INCLUSIVE 标志",说明做过真实输入框。
如果这篇文章对你有帮助,欢迎点赞、在看、转发三连。你的支持就是这个系列持续更新的动力。
「Android软件开发面试·从入门到精通」连载系列
上一篇:BottomSheet-与抽屉:容器类交互组件
下一篇预告:软键盘与焦点适配:IME-Insets-的现代方案
有任何问题欢迎在评论区留言交流。