第176篇 Span 与富文本:TextView 的高级渲染

简介: 富文本本质是“在文本区间叠加样式与行为”。面试聚焦四点:Span分类选择(Character/Paragraph/MetricAffecting/Clickable)、MovementMethod与ClickSpan协同机制、性能优化(预构建复用、MetricAffectingSpan轻量、线程安全),以及Compose中AnnotatedString的双通道设计(SpanStyle+LinkAnnotation)。细节决定成败——如SPAN_INCLUSIVE_EXCLUSIVE在编辑场景的必要性、热区偏移根因、漏设movementMethod等。

先把结论放在前面:富文本的本质是"在同一段文本上叠加一组区间(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-的现代方案

有任何问题欢迎在评论区留言交流。

相关文章
|
编解码 数据挖掘 Windows
Office 2021 for Windows 简体中文 官网下载地址
Microsoft Office 2021 Pro Plus专业增强版包括对Word、Excel、PowerPoint、Outlook、OneNote、Publisher、Access、Teams的更新。
2496 0
|
1月前
|
弹性计算 人工智能 安全
阿里云服务器99元:经济型e实例2核2G3M带宽, 新老同享,续费同价,买到的不只是一台云服务器
阿里云推出99元/年经济型e实例特惠活动,2核2G配3M固定带宽、40G ESSD Entry云盘,新老用户同享且续费同价,价格锁定至2029年3月31日。活动同步赠送3个月主机安全防护与100G免费文件备份权益,覆盖个人建站、开发测试、小微企业商用等多类场景,搭配域名、数据库等周边产品可扩展更多能力,全程享受阿里云标准7×24技术支持与SLA服务保障。
|
5月前
|
人工智能 安全 API
马斯克xAI解散,22万张GPU租给Claude,算力增加会对中国解封吗?
JeecgBoot AI专题研究 xAI 并入 SpaceX、Anthropic 拿下 Colossus 1 全部算力背后,对 Claude 用户和中国市场意味着什么?昨天,AI行业发生了一件几乎没有人预见到的事情:Anthropic与SpaceX签约,拿下了马斯克孟菲斯超算Colossus
481 1
马斯克xAI解散,22万张GPU租给Claude,算力增加会对中国解封吗?
|
4月前
|
人工智能 API 调度
0.54元/秒起!阿里云AI视频生成模型HappyHorse-1.1全网最全价格拆解,看完立省45%
阿里云HappyHorse-1.1全新升级,动态更连贯、角色更稳定、质感更真实、音画同步,支持文/图/多图生视频及编辑;限时6折,720P仅0.54元/秒起,1080P 0.72元/秒起,API已开放。开通百炼:https://t.aliyun.com/U/fPVHqY 免费领取HappyHorse免费10秒视频生成
|
6月前
|
存储 人工智能 API
DeepSeek-V4百万上下文来了,企业数据中心准备好了吗?
DeepSeek-V4虽突破模型上限,但企业落地关键在私有化部署的“落地上限”。ZStack AIOS作为国产MaaS平台,一站式解决算力池化、异构纳管、极简部署、应用集成与安全治理难题,已支持V4全系列即装即用,助力政企高效、合规、自主地用好大模型。
|
5月前
|
缓存 人工智能 运维
前后端统一协议层:​D​М‌X​Α‌РΙ 接入 glm-5.1-cc
GLM-5.1-CC 是以 GLM-5.1 为核心的工程化代码协作形态,具备200K上下文、长程任务执行(达8小时)、强工具协同与过程一致性能力。它正推动AI从“回答器”向稳定“执行器”演进,而 DMXAPI 等协议层治理方案,则为其落地 IDE、CI 等生产环境提供可观测、可编排的调用基座。(239字)
|
7月前
|
JSON 安全 算法
如何安全使用 JWT 作为访问凭证
本文深入解析JWT安全使用规范,涵盖核心概念、六步验证清单(签名、时效、签发者、受众等)、算法混淆等真实漏洞案例,并给出六大实践建议:硬编码算法白名单、严验所有声明、短生命周期令牌、过滤kid注入、选用可信库、遵循RFC 9068标准,助开发者筑牢API安全防线。(239字)
1035 0
|
11月前
|
JavaScript 前端开发 Java
java医院随访系统源码,项目级,可商用
一套基于Spring Boot的医院随访系统源码,自主版权,支持与HIS、EMR对接,实现门诊、住院、体检患者自动化随访。支持电话、短信多渠道随访,提供随访模板、知识库、三级管理、数据分析等功能
308 3
|
消息中间件 canal 缓存
缓存与数据库一致性终极指南:从入门到放弃?不,到精通!下
本文探讨缓存一致性难题,从延时双删到重试机制,分析同步重试、异步重试、消息队列补偿及Binlog监听(Canal)等方案,结合优缺点与适用场景,最终提出根据业务需求权衡一致性与性能,选择合适策略。
|
存储 SQL 关系型数据库
京东面试:mysql深度分页 严重影响性能?根本原因是什么?如何优化?
京东面试:mysql深度分页 严重影响性能?根本原因是什么?如何优化?
京东面试:mysql深度分页 严重影响性能?根本原因是什么?如何优化?