去年我在做一款AI对话类App第一版为了赶进度把后端返回的Markdown文本直接丢给WebView渲染。团队当时的想法高度一致WebView有现成的Markdown库代码高亮、表格、图片都能处理何必自己造轮子。结果上线不到两周问题一个接一个冒出来——打字机效果在WebView里做要做JS bridge消息一长WebView内存就发飘RecyclerView里塞五六个WebView之后滑动明显掉帧点击复制这类原生交互还得跟JS来回通信。后来我花了两周时间把渲染层整个换成原生TextView加SpannableStringBuilder。这一换很多困扰了半个月的问题迎刃而解。这篇文章就把这段时间攒下的经验做一个系统梳理从SpannableStringBuilder的核心机制到打字机效果的流式输出再到Markdown风格渲染、代码高亮、文本交互和性能优化。适合正在做AI聊天、智能客服、Agent助手类应用的Android工程师也适合想彻底搞懂Android富文本渲染的初级开发者。1. 为什么AI对话场景需要SpannableStringBuilder而不是WebView1.1 AI回复内容的典型特征AI对话的返回内容和普通业务文案有一个非常明显的区别它天然是高度结构化的混合文本。一段典型回复里可能同时包含加粗标题、无序列表、引用块、行内代码和代码块甚至嵌套的Markdown结构。**问题分析** - 检查网络连接是否正常 - 确认服务端接口是否可用 建议优先排查前两项。 示例 adb shell ping 8.8.8.8 kotlin val result api.fetchData() result.onSuccess { data - adapter.submitList(data) }这种文本如果只用一个普通TextView显示会是灾难级的阅读体验如果退回WebView又会遇到另一个维度的麻烦。了解这些特征之后才能理解为什么SpannableStringBuilder在这个场景下是更合适的底层选择。 ### 1.2 WebView方案的三宗罪 先说打字机效果。AI服务的输出通常是SSE流式返回客户端要边收边展示。WebView要实现这种逐字出现的效果要么通过JS接口频繁操作DOM要么每个chunk都重新load一遍HTML。第一种方案在消息越长时越卡因为每次插入节点都可能触发重排第二种方案每次加载都是全局刷新页面会闪、滚动位置会丢、之前注入的JS状态也保不住。 其次是内存与性能。单个WebView进程的默认内存占用通常是几十MB起步聊天列表里如果同时存在五六个WebView内存直接奔着几百MB去。Android系统对WebView的回收策略在不同机型上表现差异很大低端机上很容易出现页面白屏用户返回上一个界面再进来看到的可能是一个空白区域。 最后是交互割裂。AI对话界面需要很多原生交互长按复制代码、点击链接跳转、呼出分享面板等。这些能力在WebView里都要通过JS bridge绕一圈增加大量胶水代码而且事件传递、焦点管理、键盘弹起这些问题在WebView里都更难控制。很多团队在WebView方案里能跑通但每次迭代都会发现新的边界问题。 ### 1.3 原生TextView加SpannableStringBuilder的优势 换到原生方案之后这些问题的复杂度直接下降了一个量级。TextView里的所有样式都是内存中的数据对象可以增量修改、按需替换、随手移除不需要像HTML那样每次改一个字符都重新解析整段标签结构。 SpannableStringBuilder是Android官方提供的可变CharSequence实现文本片段和样式Span可以在任意时刻增删改。这天然就是为流式输出设计的收到一个文本片段append进去、打上样式再丢给TextView去重绘。每次更新只是把增量部分交给TextView而不是像WebView那样整体重新加载开销小得多。 原生交互也是天然支持的。ClickableSpan能处理点击文本选择、复制、分享走系统的原生行为RecyclerView复用时也只需要关心Span的覆盖与清理。这些能力对于聊天界面来说几乎是刚需。 ## 2. SpannableStringBuilder核心机制拆解从setSpan到Span层级管理 ### 2.1 三个关键概念SpannedString、SpannableString、SpannableStringBuilder 很多初学者刚接触Android的字符序列时会被这三类搞晕。我用一个生活化的类比来解释 - SpannedString像一份打印好的合同内容和批注都不允许修改。 - SpannableString像一份PDF正文内容固定但可以在上面加高亮、加批注批注可以改。 - SpannableStringBuilder像一篇Word文档正文和批注都能随时改。 写AI对话界面时绝大多数场景都用SpannableStringBuilder因为它既要持续追加文本又要动态维护样式区间。 kotlin val builder SpannableStringBuilder() builder.append(Hello) builder.setSpan( ForegroundColorSpan(Color.RED), 0, builder.length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE )如果只是展示一段已经定稿的富文本用SpannableString就够了性能上还略好一点如果文本本身和样式都需要持续变化选Builder就对了。2.2 setSpan的flags参数到底怎么选setSpan的第四个参数flags是很多人在流式输出场景里翻车的重灾区。四个参数的含义是SPAN_EXCLUSIVE_EXCLUSIVE前后插入内容都不包含该样式。SPAN_EXCLUSIVE_INCLUSIVE后面追加内容会被包含。SPAN_INCLUSIVE_EXCLUSIVE前面插入内容会被包含。SPAN_INCLUSIVE_INCLUSIVE前后插入内容都会被包含。在AI聊天里最常见的场景是模型正在输出一段代码代码块还没闭合时我们需要对整个正在代码块中的文本应用一个临时背景色。如果创建这段背景Span时用了SPAN_EXCLUSIVE_EXCLUSIVE那么后续append进来的换行和代码内容都不会被包进背景里视觉上就会断裂成一行一行的碎块。正确的做法是在进入代码块状态时用SPAN_EXCLUSIVE_INCLUSIVE或SPAN_INCLUSIVE_INCLUSIVE设置背景Span之后再append的内容就自动延续这个样式不需要每次追加都去setSpan。我的实践经验是流式渲染里除了一些明确与边界无关的样式尽量统一使用SPAN_EXCLUSIVE_INCLUSIVE可以少踩很多坑。它能让Span的边界随意向后延伸而不会因为后续文本插入导致样式错乱。2.3 常用Span分类与选型对照表Android的Span体系主要分成四类我按用途做了个选型表分类代表类典型用途CharacterStyleForegroundColorSpan、BackgroundColorSpan、StyleSpan、UnderlineSpan文字颜色、背景色、粗体、斜体、下划线MetricAffectingSpanAbsoluteSizeSpan、RelativeSizeSpan、TypefaceSpan字号大小、字体切换ParagraphStyleLeadingMarginSpan、QuoteSpan、BulletSpan段落缩进、引用线、列表圆点ClickableSpan / ReplacementSpanClickableSpan、自定义ReplacementSpan点击事件、自定义绘制内容在实际的AI对话界面里一套基础的Markdown渲染最少用到ForegroundColorSpan负责正文颜色和行内代码颜色BackgroundColorSpan负责代码块背景StyleSpan负责加粗斜体QuoteSpan负责引用块LeadingMarginSpan负责列表缩进ClickableSpan负责链接和标签点击。不要一个Span解决所有问题。好的设计是每种样式职责单一然后通过组合形成最终效果。2.4 已有内容的样式修改与Span移除流式输出并不是永远只追加偶尔也会遇到需要回头修改的情况。比如说后端在输出末尾追加了一个修正说明要把前面已经显示为错误的文字改成已更正的颜色。先看怎么替换Span// 移除旧色 val spans builder.getSpans(start, end, ForegroundColorSpan::class.java) spans.forEach { builder.removeSpan(it) } // 重新设置新色 builder.setSpan( ForegroundColorSpan(Color.GREEN), start, end, Spannable.SPAN_EXCLUSIVE_INCLUSIVE )这里有一个非常值得注意的点getSpans返回的数组里面的类型参数不是普通类而是实际Span类的Class所以可以精准地只取出某种Span。如果写成了ForegroundColorSpan::class.java它能拿到范围内的所有前景色Span再把它们全部移除。不过有一点要注意removeSpan之后这个位置就不再有任何样式后续重新setSpan时新的Span会覆盖整个范围不会有旧样式残留。所以在做修订类操作时先删旧样式再设新样式是安全的顺序。3. 打字机效果实现流式输出中最容易翻车的三个细节3.1 增量更新与BufferType的选择很多人实现打字机效果时习惯每收到一个字符就new一个新的StringBuilder拼完之后setText一遍。这种写法在短文本上没感觉一旦消息到了几千字字符串拼接加重新解析的开销就会肉眼可见地拖慢UI。正确的做法是始终维护一个SpannableStringBuilder实例每次收到新的chunk就append进去然后直接调用textView.setText(builder)。因为传入的是一个Spannable对象TextView会保留这个对象的引用不会做无谓的拷贝。private val messageBuilder SpannableStringBuilder() fun onNewChunk(chunk: String) { messageBuilder.append(chunk) binding.messageText.setText(messageBuilder) }这里还要注意TextView的BufferType。如果直接调用setText(CharSequence)TextView在内部会把非Spannable的CharSequence转换成SpannedString但传入Spannable对象时不会另存一份而是直接使用原对象。只有在调用setText(CharSequence, BufferType.SPANNABLE)时TextView才会把内容复制到自己的SpannableStringBuilder中。为了性能我通常直接用setText(builder)并依赖原始对象这样Builder里后续的span变化也能被TextView感知。3.2 节奏控制与合并更新SSE流式返回的频率很不稳定有时一秒钟来十几个小chunk有时几百毫秒才来一个。如果来一个chunk就刷新一次TextView可能在无意义地浪费性能。更好的做法是做一个节流private var lastUiUpdateTime 0L fun onNewChunk(chunk: String) { messageBuilder.append(chunk) val now SystemClock.uptimeMillis() if (now - lastUiUpdateTime 16L) { binding.messageText.setText(messageBuilder) lastUiUpdateTime now } }这里16毫秒对应一帧的刷新间隔意思是同一帧内无论来了多少个chunk只会真正刷新一次UI。帧率高的设备也不会因为日志输出过于频繁导致掉帧。有人可能会问那中间的多个chunk怎么办很好办TextView绘制时会读取当前builder里的全部内容所以即使跳过了前面几次setText最后一次性setText时内容也是完整的不会有丢字。3.3 光标闪烁与滚动跟随打字机效果如果只有文字出现没有光标用户分不清当前是不是还在输出。比较常见的做法是在builder尾部追加一个|字符然后用一个定时器控制它的可见性。注意两点光标字符不能影响文本复制如果用户在输出过程中手动复制了文本光标字符会被一起带出来体验很糟。所以更稳妥的做法是在流式输出的界面上用一个单独的View画光标或者用BlinkSpan搭配不可见占位符。滚动跟随也是一个看起来简单但容易出问题的地方。消息列表在输出过程中需要保持滚动到底部但是不能每次都直接smoothScrollToPosition因为smooth滚动是有动画的如果每帧都触发会出现滚动抖动。我的做法是先判断当前用户是否本来就在底部区域如果在底部smoothScrollToPosition一行如果用户已经上翻看历史就不打扰他。fun onUiUpdate() { val manager binding.recyclerView.layoutManager as LinearLayoutManager val lastVisible manager.findLastVisibleItemPosition() val total manager.itemCount - 1 if (lastVisible total - 1) { binding.recyclerView.smoothScrollToPosition(total) } }3.4 生命周期与协程取消打字机效果的输出是异步任务如果Activity或Fragment已经销毁回调里的UI更新就会崩溃。强烈建议所有流式更新都在ViewModel或协程里做然后把UI的赋值操作放到lifecycleScope里在onDestroy取消。更简单的方式用一个AtomicBoolean标记当前页面是否销毁在回调开始时先检查一次。if (!isActive) return这几个细节都处理好之后打字机效果才算真正稳定不会再被测试追着报bug。4. Markdown风格渲染落地代码高亮、行内样式与引用块4.1 用正则识别核心Markdown结构要自己用SpannableStringBuilder实现Markdown风格的渲染第一步是找到每种语法在文本中的准确位置。正则表达式是最直接的工具。val codeBlockRegex Regex((\\w)?\\n([\\s\\S]*?)\\n) val inlineCodeRegex Regex(([^]?)) val boldRegex Regex(\\*\\*(.?)\\*\\*) val italicRegex Regex((?!\\*)\\*([^*]?)\\*(?!\\*)) val quoteRegex Regex(^\\s?(.)$, RegexOption.MULTILINE)需要提醒的是正则匹配的时候要小心模式之间的重叠。比如加粗的正则\*\*(.?)\*\*会把代码块里的**也匹配进去所以处理顺序很重要。我通常的规则是先处理代码块和行内代码因为它们的优先级最高然后处理段落级结构最后才处理加粗、斜体这类更通用的行内样式。工具方法可以写成这种形式先拿到匹配结果再在匹配区间上应用Spanfun applyRegexStyle( builder: SpannableStringBuilder, regex: Regex, style: (start: Int, end: Int) - Any ) { regex.findAll(builder).forEach { match - builder.setSpan( style(match.range.first, match.range.last 1), match.range.first, match.range.last 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE ) } }4.2 代码块高亮不依赖第三方库的实现思路代码高亮是AI对话界面里用户感知最强的功能之一。完全从零手写一个语法高亮器不现实但对于常见的编程语言用正则做基础分词的思路反而最可控。我的做法是维护一组预编译的Pattern分别匹配关键字、字符串、数字和注释data class TokenPattern( val name: String, val pattern: Pattern, val color: Int ) private val tokenPatterns listOf( TokenPattern( keyword, Pattern.compile(\\b(val|var|fun|class|object|if|else|for|while|return)\\b), Color.parseColor(#CC7832) ), TokenPattern( string, Pattern.compile(\(\\\\.|[^\\\\\])*\), Color.parseColor(#6A8759) ) )匹配的时候遍历整个代码块文本对每个Pattern做一次匹配然后根据匹配区间设置ForegroundColorSpan。这里有个性能关键点Pattern一定要提前编译好不要在渲染循环里现编正则否则性能开销非常大几千字的代码块会直接卡顿。还要注意匹配顺序关键字和数字不能同时在同一个位置重复匹配。可以维护一个已占用区间列表匹配结果先做交集判断或者按优先级顺序逐个匹配并跳过已占用的位置把后到的Span覆盖放在已存在的Span之上。4.3 引用块与列表的缩进处理AI回复里经常有引用和列表这些是段落级结构需要用到ParagraphStyle系列的Span。引用块用QuoteSpan最简单它会在左侧画一条竖线并加缩进builder.setSpan( QuoteSpan(Color.GRAY, 2.dp, 16.dp), quoteStart, quoteEnd, Spannable.SPAN_EXCLUSIVE_INCLUSIVE )但QuoteSpan有个问题它不支持发生在段落中间的引用块因为段落级Span的边界会作用于整个段落。如果AI生成的内容是一行引用接着一段正文正则匹配出来的是不连续的区间直接setSpan容易把正文也带上缩进线。我的处理方案是在设置QuoteSpan之前先把包含引用的整个段落里非引用文本用\n分隔出来然后只对引用行本身应用QuoteSpan。这需要做一次预处理把文本按段落拆分并重建builderval paragraphRegex Regex((?m)^.*$) paragraphRegex.findAll(builder).forEach { lineMatch - val line lineMatch.value if (line.startsWith( )) { val start lineMatch.range.first val end lineMatch.range.last 1 builder.setSpan( QuoteSpan(Color.GRAY, 2.dp, 16.dp), start, end, Spannable.SPAN_EXCLUSIVE_INCLUSIVE ) } }列表项用LeadingMarginSpan会更灵活它可以指定缩进宽度和绘制标志。如果你想做自定义的项目符号也是继承LeadingMarginSpan自己画。4.4 流式增量渲染的策略流式输出和一次性渲染最大的区别在于文本是逐步到达的很多Markdown结构在某一时刻是不完整的。代码块可能还没闭合列表可能才刚出现一行。如果每次chunk到达都对完整文本做一次正则匹配随着文本增长时间复杂度会越来越高动辄几百毫秒一帧。我的实践方案是段级增量渲染维护一个lastProcessedIndex记录完成过解析的文本位置。每次收到chunk先append到builder但只对新增的文本部分做初步处理。对于代码块用状态机判断当前是否在代码块内部。如果在代码块内部持续给新增文本设置进行中的背景色Span但先不做语法高亮。当检测到代码块闭合标记后从代码块起始位置到闭合位置做一次完整的高亮渲染同时把之前设置的临时背景Span替换成正式背景Span。这样做的好处是正文部分几乎零成本代码块只在真正闭合时渲染一次不产生全文重复正则匹配的开销。用户看到的效果是代码块内容在输出过程中一直有底色输出完成后才出现关键字高亮视觉上非常自然。5. 交互扩展点击、复制、选择与RecyclerView复用陷阱5.1 ClickableSpan在AI聊天里的实际用途AI对话里的纯文本展示只是基础关键交互能力往往来自Span。ClickableSpan能做的事情很多让链接可点击、让用户标签可跳转、让代码块右上角的复制按钮触控区域与文本绑定。给一段文本设置ClickableSpan的写法builder.setSpan( object : ClickableSpan() { override fun onClick(widget: View) { // 跳转逻辑 } }, start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE )但这里有一个前置条件TextView必须设置MovementMethod否则ClickableSpan不会响应点击。binding.messageText.movementMethod LinkMovementMethod.getInstance()MovementMethod一旦缺失你点击文本什么都发生不了很多新手第一次用ClickableSpan都会栽在这里。5.2 自定义Span实现特殊展示效果除了系统提供的SpanAI对话界面经常需要一些定制化视觉比如翻译按钮、代码块复制按钮、标签底色、表格边框等。这些场景可以通过自定义ReplacementSpan实现。一个简单的例子把复制代码做成一个可点击的文字按钮并给它一个圆角背景。class CopyActionSpan( private val onClick: () - Unit ) : ReplacementSpan() { override fun getSize( paint: Paint, text: CharSequence?, start: Int, end: Int, fm: FontMetricsInt? ): Int { return paint.measureText(text?.subSequence(start, end).toString()).toInt() padding * 2 } override fun draw( canvas: Canvas, text: CharSequence?, start: Int, end: Int, x: Float, top: Int, y: Int, bottom: Int, paint: Paint ) { // 绘制圆角背景和文字 } }不过要注意ReplacementSpan的draw回调运行在绘制线程如果在里面做网络请求或复杂计算会直接卡UI。像复制代码这种逻辑应该只更新span的点击状态真正的复制动作放到onClick里执行。5.3 文本选择与MovementMethod的冲突textIsSelectabletrue和ClickableSpan之间存在一个天然的冲突文本进入选择模式后点击行为会被系统拦截ClickableSpan无法触发。Chat应用里用户又经常需要既能长按选择文本又能正常点击链接。这个问题有几种解法。最简单的方案是在点击时判断当前是否处于选择模式。binding.messageText.customSelectionActionModeCallback object : ActionMode.Callback { override fun onActionItemClicked(mode: ActionMode?, item: MenuItem?): Boolean false override fun onCreateActionMode(mode: ActionMode?, menu: Menu?): Boolean true ... }更彻底的做法是自定义一个MovementMethod继承LinkMovementMethod在onTouchEvent里处理好按下和抬起的坐标再调用super。这样能同时支持点击和长按选择。5.4 RecyclerView消息列表的Span复用陷阱很多聊天界面是把一条消息封装成一个ViewHolder放进RecyclerView用SpannableStringBuilder做渲染后直接绑定给TextView。这里有个非常隐蔽的问题ViewHolder回收复用时屏幕上新消息第一次显示如果直接setText一个全新的CharSequence一切正常但如果复用的是旧消息的TextView新消息没有显式清除旧Span残留样式就会闪现一下再被覆盖。我的习惯是每次绑定必然调用一次binding.messageText.setText(builder)并且在流式输出期间如果这条消息的TextView被复用去显示别的消息需要立刻取消这个builder的继续更新。实现方式是给每个消息关联一个token在onBindViewHolder时更新token流式回调里检查token是否匹配不匹配就丢弃。data class MessageItem( val id: Long, val builder: SpannableStringBuilder ) fun bind(item: MessageItem) { binding.messageText.setText(item.builder) currentBindingId item.id }token匹配不仅避免了Span残留还能防止后台线程在ViewHolder被回收后继续往已经不显示的对象里写数据。6. 性能优化与踩坑实录实测数据与分析6.1 长文本卡顿的根因我自己实测过一段包含多个代码块、总长度5000字左右的AI回复如果用全文正则匹配加整段重新渲染初次渲染耗时普遍在100毫秒以上部分低端机上能到300毫秒。这个延迟在打字机效果中会表现为输出有明显的停顿感用户体感很差。根因主要在三处正则匹配扫描整个文本且每次chunk到达都会扫描一遍复杂度随文本增长线性上升。Span数量过多时TextView的绘制阶段需要遍历Span列表去做样式叠加。文本过长时TextView自带的自动换行计算本身就耗时。6.2 减少Span数量的策略优化思路很明确减少冗余Span。最常见的冗余是给连续多行同一代码块里的文本每一行都设置一个独立的BackgroundColorSpan。更好的做法是只给代码块的起始和结束位置设置一个段落级Span或者拆成每行一个但复用同一个Span实例。复用实例比每次new一个能节省不少对象分配。另一个建议是对于加粗、行内代码这类行内样式尽量只在文本相对较短时使用独立的Span当文本长度超过几百字时优先考虑重构成段落级样式。6.3 增量渲染与缓存设计把渲染过程从全量渲染改成增量渲染之后性能提升非常明显。上面说的段级增量渲染方案可以把每次chunk到达时的处理时间控制在1毫秒以内只有在代码块闭合时才会触发一次几十毫秒的重渲染。另外一个容易被忽略的性能点是不要把SpannableStringBuilder每次重新生成而是要长期持有、反复复用同一个对象。这样后续的append操作不会触发旧的Spannable内存再分配。如果需要缓存历史消息用HashMap或Room存原始字符串就够了流式结束后的定稿渲染结果也可以缓存在内存里。这样再次进入页面时直接显示定稿样式而不是重新解析。6.4 实测结论与几个小技巧我用一段约1500字的代码块和3000字正文做了对比测试全量渲染模式首次渲染约120ms后续每次chunk重渲染约80ms明显影响打字机流畅度。增量渲染模式首次渲染约120ms后续chunk处理在1ms级别只有代码块闭合时有约40ms的重渲染用户基本无感知。实测下来还有几个小技巧值得分享设置TextView时避免每次都调用setText(CharSequence, BufferType.SPANNABLE)直接setText(spannable)即可内部会走更短的路径。大量文本的TextView不要设置android:textIsSelectabletrue长按选择在长文本上的性能损耗很大。流式输出时不要在每帧都调用requestLayout只用invalidate做绘制级别的刷新可以省掉不必要的布局测量开销。关于代码高亮和Markdown渲染如果项目时间紧我也建议参考Markwon这类成熟库的实现思路但不建议直接依赖。因为它们大多面向一次性渲染流式场景下需要自己处理相位推进和状态管理改造起来反而比从头写更费劲。这次重构下来我最大的体会是富文本渲染没有银弹WebView有WebView的边界原生TextView加SpannableStringBuilder也有它的工作量但至少在AI对话这个高交互、高频更新的场景里原生方案的可控性和性能表现都明显胜出。把这些机制吃透后面再做任何富文本需求心里都会有底得多。
