
这段时间在做一款AI对话App最让我头疼的不是接口对接也不是流式返回的数据格式而是消息气泡里的那个TextView——它既要像打字机一样一个字一个字往外蹦又要在内容全部到达之后自动展示出代码块、加粗、行内代码、列表这些格式。如果只会setText()往里塞字符串那这个聊天界面做出来就是灾难。后来我把SpannableStringBuilder从“能用”摸到“好用”顺手把打字机效果、富文本解析、代码块高亮、点击复制这一整条链路都打通了。这篇文章就围绕这个主题把我踩过的坑和最终的实现方案完整拆给你看。SpannableStringBuilder这东西平时大家都不陌生但绝大多数人只用过它给某几个字变个颜色。真到AI对话场景里你会发现它就是Android上做富文本最趁手的工具可以动态追加文本可以在任意区间盖Span可以局部替换而不重建整个字符串而且TextView天生支持它不需要额外引入什么重型渲染组件。如果你现在正好在做类似的对话式产品或者想把项目中那种“只会变色的TextView”升级成真正能扛住流式输出的富文本控件这篇文章应该能省下你不少排查时间。1. 整体设计为什么是SpannableStringBuilder而不是WebView或H51.1 选型对比TextView Spannable vs WebView vs 第三方Markdown库先聊聊选型这件事。做AI对话富文本渲染市面上大概有三条路第一是直接上WebView让前端同事写个页面加载Markdown第二是引入现成的Markdown渲染库把文本转成CharSequence或HTML第三就是自己在SpannableStringBuilder上做。WebView的方案看着省事但在聊天场景里真的不推荐。每条消息都是一个WebView内存开销大不说RecyclerView滚起来会明显掉帧。更麻烦的是WebView里的文本选择和原生TextView的ActionMode不统一长按复制、跨消息选择这些交互都很割裂。如果你只想展示一下Markdown效果WebView没问题但在一个高频更新、流式输出的对话流里它太重了。第三方Markdown库也是个思路比如Markwon这类库本身也基于SpannableStringBuilder实现开箱即用支持常见Markdown语法和部分代码高亮。但我个人在实际项目中还是选择了自己控制解析和渲染。原因是AI流式输出场景太特殊了——文本是一点点进来的Markdown结构可能暂时不完整比如代码块的三反引号还没闭合通用Markdown库在这种中间状态下很难处理。自己控制解析逻辑才能在“实时反馈”和“最终效果”之间做取舍。最后是原生SpannableStringBuilder方案。它的优势在于第一它是Android框架自带的零依赖第二它对TextView有原生支持setText(CharSequence)直接可用没有跨层转码开销第三它的水平足够高——颜色、字体、背景、点击、缩进、引用、上下标全都能用不同Span组合出来。打字机效果本质上就是在同一个SpannableStringBuilder上持续append这是WebView很难做到优雅的。1.2 对话消息的数据模型与渲染粒度聊完选型我给出一套当时设计的消息数据模型这是渲染逻辑的基础。一条AI消息不能只存一个纯文本字段至少要拆成三层原始文本、解析后的富文本、渲染状态。data class ChatMessage( val id: String, val role: Role, // USER / ASSISTANT val fullContent: String, // 最开始的完整文本流式结束后才有值 var renderedContent: SpannableStringBuilder?, // 渲染后的富文本 var status: RenderStatus // IDLE / STREAMING / COMPLETED )我之所以强调渲染粒度是因为很多人一上来就把整条消息塞给解析器。这在一句话消息里没问题但如果AI一次返回几百行代码你的解析器和TextView都会很吃力。合理的做法是流式期间只做轻渲染流式结束后再做全量重渲染。轻渲染包括识别行内代码、特殊标记和段落边界重渲染才做真正的代码高亮和完整Markdown解析。这种分层还有一个额外好处富文本渲染非常耗时的时候可以先给用户一个可读的纯文本界面再异步把格式化结果替换上去。相比那些等全部解析完才显示的实现体验要好一大截。2. 打字机效果进阶流式场景下的文本增量更新2.1 流式输出的基本姿势先落地最简单的打字机效果。很多人写流式第一版是这么干的收到一个字符就setText()一次。真这么做你会发现两个问题一是频繁setText()会触发TextView全量测量和重绘UI线程稍微忙一点就开始卡顿二是间隙感太强文字不是流畅打出来的而是一跳一跳的。更好的方案是维护一个SpannableStringBuilder把收到的数据块批量追加进去再整体setText。下面是经过实践打磨后的基础版本class StreamingTextViewModel { private val contentBuilder SpannableStringBuilder() private val textView: TextView? null fun onChunkReceived(chunk: String) { // 把新到达的文本追加到同一个builder里 contentBuilder.append(chunk) // 高频更新时不是每次append都立即刷新 // 而是通过Choreographer或post延迟合并刷新 scheduleRender() } private fun scheduleRender() { textView?.post { textView.text contentBuilder // 滚动到底部让用户始终看到最新内容 if (autoScrollEnabled) { scrollToBottom() } } } }这里有个细节scheduleRender()里我用的是post并不是真正合并了刷新。如果你希望合并可以在一个16ms~50ms的窗口内缓存多次chunk然后一次性刷新。最简单的做法是维护一个待追加列表用一个Handler定时取走并appendprivate val pendingChunks java.util.concurrent.ConcurrentLinkedQueueString() private val handler Handler(Looper.getMainLooper()) private val refreshRunnable object : Runnable { override fun run() { var chunk pendingChunks.poll() if (chunk ! null) { contentBuilder.append(chunk) textView.text contentBuilder scrollToBottom() } handler.postDelayed(this, 16) // 约为60fps的刷新间隔 } }这样既不会每来一个字符就刷新一次也保证了视觉上接近实时。按这个间隔AI输出速度快的时候文字会一小段一小段地显示观感上已经很接近“格子机稳定输出”了。2.2 分段append与滚动到末尾如果只是简单聊天一个View直接更新就够了。但真实场景里AI返回的内容经常是一大段Markdown里面混着表格、代码块、列表。流式过程中如果整块文本都放在同一个TextView里用户一旦复制代码或者点击链接交互范围就变得很别扭。所以我后来把一条AI消息内部的“段落”拆成了多个渲染单元每个单元就是一个SpannableStringBuilder。流式期间我判断当前文本的顶部结构决定新到的字符应该追加到哪个单元里如果当前在普通文本段落就追加到当前段落builder如果检测到代码块开始标记就新建一个代码块builder并标记当前状态为IN_CODE_BLOCK代码块结束后再切回普通段落builder。对应的可视化效果是普通文本用默认样式代码块用等宽字体加灰色底色两者在同一个气泡内紧凑排列。这个设计比“整条消息一个巨大TextView”更接近真实AI产品的交互体验。这里要特别提一下滚动问题。很多人的scrollToBottom直接写在setText后面但我实测发现有问题setText之后TextView的尺寸还没更新这时候滚动到底部会滚不到最下方。稳妥的做法是通过View.post或者监听onLayout之后再滚fun scrollToBottom() { scrollView.postDelayed({ scrollView.fullScroll(View.FOCUS_DOWN) }, 50) }这个延迟很关键设置得太短会失效太长会显得生硬。50毫秒左右是我调出来的经验值大家可以按自己场景微调。3. 富文本核心细节Span的拼装与渲染机制3.1 常用Span速查与选择SpannableStringBuilder最核心的思路我习惯用一句话记文本是一个长的字符序列Span就是在某些区间上叠加的样式标注。它很像Excel里给单元格加底色、加粗、加边框数据没变但呈现方式完全不同。下面是我在AI对话里用得最多的Span整理成了一个速查表需求场景使用的Span属性说明文字颜色ForegroundColorSpan最常用适合关键字、强调文字背景色BackgroundColorSpan适合行内代码、高亮块整段背景LineBackgroundSpan适合代码块、引用块按行绘制背景加粗/斜体StyleSpan(Typeface.BOLD)注意不是直接改字体而是style等宽字体TypefaceSpan(monospace)代码、路径等场景必备点击事件ClickableSpan必须配合MovementMethod才生效引用块QuoteSpan左侧竖线适合引用内容列表项BulletSpan自动带圆点缩进链接URLSpan点击打开浏览器需要LinkMovementMethod上下标SuperscriptSpan/SubscriptSpan化学式、注脚场景用得少但库里有字号RelativeSizeSpan/AbsoluteSizeSpan相对/绝对字号控制选Span的时候有个容易被忽略的点不是所有Span都适合频繁更新。像LineBackgroundSpan这种绘制重的Span在长文本里数量一多重绘成本就上去了。Streaming场景我会优先用BackgroundColorSpan它对绘制性能更友好底色的“块状感”可以通过设置统一的段间距来弥补。3.2 代码块与行内代码如何在TextView里做出“编辑器”的观感代码块是AI对话里最典型的富文本场景。用户让AI写一段Python或Shell返回的内容就是一坨代码里面还混着中文注释。如果跟普通文本一起渲染观感非常差。我做过一个“两阶段上色”方案在流式场景里很实用第一阶段代码块已经在内容里识别出来但还没完全结束比如反引号没落下来。这时我会给这块内容统一套上“等宽字体 灰色背景”两个Span先保证视觉上与普通文本区分开。这个阶段不追求语法高亮只看结构。第二阶段整个消息流式结束、代码块闭合后再触发一次完整解析把代码块的纯文本提取出来交给一个语法高亮器分词再为每个token设置颜色Span。注意这一步是用一个新的SpannableStringBuilder替换旧的渲染结果不是原地改避免文本位置偏移导致Span范围错乱。行内代码更简单只需要识别包围的片段包上TypefaceSpan(monospace)和BackgroundColorSpan。需要注意的坑反引号在流式中可能先出现一个这时候不能判断为行内代码起始因为它可能是代码块三反引号的第一段。我的处理是只有遇到两个连续反引号才认为行内代码开始如果遇到三个连续的则立即切到代码块模式。这个细节处理好了就不会出现“代码块刚开始时整块被当行内代码染成灰底”的尴尬情况。3.3 一个够用的轻量Markdown解析器完整的Markdown解析器工作量大但AI对话里高频语法其实就那么几种粗体、行内代码、代码块、链接、标题、无序列表。手写一个够用的轻量解析器并不难关键是选好解析路径。我推荐用“正则提取 多轮替换”的思路而不是一次性写一个状态机。举个例子解析粗体和行内代码val regex Regex(\*\*(.?)\*\*|([^])) regex.findAll(rawText).forEach { match - val group match.groups[1] ?: match.groups[2] val start match.range.first val end match.range.last 1 if (group ! null) { if (match.value.startsWith(**)) { sb.setSpan(StyleSpan(Typeface.BOLD), start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) } else { sb.setSpan(TypefaceSpan(monospace), start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) sb.setSpan(BackgroundColorSpan(0xFFEEEEEE.toInt()), start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) } } }注意这里的边界match.range是正则匹配到的元字符位置包含**或反引号本身。如果你不想把星号也渲染出来就要在解析前先从文本里“消费”掉这些标记字符再在残余文本上设置Span。实操时我习惯用一个“删除标记字符”的辅助方法先把标记符替换为空字符串再对替换后的位置做Span映射。这一步如果做不细致就会出现文本内容里有星号露出来的情况。不管你是自己写解析器还是用现成库建议都记住一个原则解析出来的Span一定是基于某个字符串快照的流式过程中文本一直在变Span的区间如果依赖旧字符串位置就会出现错位。所以最好是先维护一份完整纯文本每次重新解析时从这份纯文本生成新的SpannableStringBuilder再用新builder整体替换TextView的文本。4. 实战把聊天气泡做成可交互的富文本区域4.1 ClickableSpan与TextView的点击链路富文本聊天气泡里最常见的一个交互是点击代码块复制代码。这也是一些新手容易卡住的地方明明给代码块设置了ClickableSpan点击却没反应。原因是TextView默认没有开启“可点击Span”的检测你必须给它设置一个MovementMethod最常用的是LinkMovementMethod。只有设置了MovementMethodTextView才会在触碰时去检查当前点击的位置有没有挂着ClickableSpan有的话就回调它的onClick()。tvMessage.movementMethod LinkMovementMethod.getInstance()但这里有一个坑LinkMovementMethod一旦设置TextView的长按和链接点击就会冲突。用户在含有ClickableSpan的文本上长按会被移动事件判定成正在“按下链接”长按复制根本弹不出来。我的解决办法是自定义一个ModularMovementMethod它继承LinkMovementMethod在长按事件发生时先走自己的长按逻辑class CustomLinkMovementMethod( private val onLongPress: (() - Unit)? null ) : LinkMovementMethod() { override fun onTouchEvent(widget: TextView, buffer: Spannable, event: MotionEvent): Boolean { when (event.actionMasked) { MotionEvent.ACTION_UP - { // 先取链接再判断手势时间 val x event.x - widget.totalPaddingLeft widget.scrollX val y event.y - widget.totalPaddingTop widget.scrollY val offset buffer.getOffsetForPosition(x, y) val link buffer.getSpans(offset, offset, ClickableSpan::class.java).firstOrNull() if (link ! null event.eventTime - event.downTime 300) { link.onClick(widget) return true } onLongPress?.invoke() return true } } return super.onTouchEvent(widget, buffer, event) } }这个方案的核心是短按且按在ClickableSpan上走点击回调长按或没按在Span上走我们自定义的长按逻辑。这样代码块既能点击复制长按又能弹出系统复制菜单两者不打架。4.2 流式场景中的Span更新策略打字机效果和富文本解析之间存在一个“实时性vs准确性”的矛盾。我实践下来的最佳策略不是二选一而是分阶段处理。流式期间我维护两个结构plainTextBuilder一个普通的StringBuilder负责累积收到的纯文本保证消息内容不丢。lightSpannable一个SpannableStringBuilder追加新文本时只做结构识别比如遇到代码块就套底色遇到行内代码就套等宽字体。流式结束时再触发一次fullRender()拿到plainTextBuilder的完整字符串调用完整的Markdown解析和代码高亮器生成最终的SpannableStringBuilder替换掉原来的lightSpannable。fun onStreamingFinished() { val raw plainTextBuilder.toString() contentBuilder.clear() contentBuilder.append(parseFullMarkdown(raw)) textView.text contentBuilder }这样写的好处是流式时用户可以立刻看到“这段可能是代码”的视觉提示体验流畅结束后文本格式自动“精致化”该加粗的加粗该高亮的代码高亮不会因为中途解析不完整而出现错乱。如果AI中途断流或超时至少plainTextBuilder里文本是完整的可以走一个安全降级不做Markdown解析直接把纯文本回归展示。4.3 长按复制与复制按钮除了点击事件复制是最常用的交互。我给代码块和普通文本分别做了处理。代码块点击复制直接在ClickableSpan.onClick()里拿到ClipboardManager写入纯文本。注意这里要写纯文本不能把带Span的SpannableStringBuilder直接塞进剪贴板否则对方拿到会是一堆不可控的富文本格式。长按复制则依靠TextView自带的长按动作前提是TextView本身可复制。我遇到过一个坑如果TextView的textIsSelectable设为true它会优先拦截一切touch事件导致CustomLinkMovementMethod的onTouchEvent根本没机会执行。所以在同时需要“代码块点击复制”和“长按选词复制”的场景里我建议不要启用textIsSelectable而是自己实现长按回调弹出自定义的复制菜单行为更可控。5. 性能与细节优化让长对话也保持流畅5.1 防止SpannableStringBuilder无限膨胀打字机和流式更新最容易踩的坑就是让SpannableStringBuilder无限增长。AI对话是历史累积的用户可能翻旧消息也可能当前消息非常长。如果每条消息都是“完整的一个SpannableStringBuilder”内存占用会越来越大RecyclerView滚动也会越来越卡。我采取的方案是按需截断当消息文本超过一个阈值比如5万字符后面的内容不再进入实时渲染而是做成“点击加载更多”或者直接分页显示。阈值可以按机型调整但总体原则是保证单条消息的渲染时长低于16ms。另外如果一条消息内容真的特别长可以把它的渲染拆成“首屏可见部分”和“后续部分”。首屏优先渲染用户当前能看到的部分其余部分延迟渲染或异步渲染。这个策略虽然增加了一点复杂度但对体验提升非常明显尤其是代码块很长的时候。5.2 RecyclerView复用与Span状态丢失RecyclerView的Item复用是另一个大坑。很多人把SpannableStringBuilder存在View的tag里或者存在Adapter对应的data里但Item复用时上一次的Span数据会被残留下来。我的做法是Adapter的onBindViewHolder里对textView做一次“四清”操作textView.clearComposingText()textView.text 如果当前viewHolder.textView曾经设置了自定义MovementMethod重新设置重新绑定数据时每次都new一个新的SpannableStringBuilder不要复用旧的。这样虽然每次都会多new一个小对象但在现代ART虚拟机下成本可控换来的是状态绝对不会串。5.3 常见问题排查实录最后把我在开发过程中遇到的典型问题整理成一张速查表这些问题如果没提前控制排查起来真的很耗时间。现象可能原因解决方案点击代码块没反应TextView未设置MovementMethod设置LinkMovementMethod或自定义可兼容长按的MovementMethod长按复制被链接事件拦截LinkMovementMethod与长按冲突自定义MovementMethod短按走ClickableSpan长按走自己的逻辑代码块背景色一块一块断开的用的BackgroundColorSpan但有换行导致的间隙改用LineBackgroundSpan或者控制好Span覆盖范围包含换行符流式更新特别卡每个字符都setText合并批量更新16ms窗口刷新一次消息结束后的Markdown错乱解析时Span位置基于“旧文本”但文本又追加了新内容用纯文本快照重新解析生成新的SpannableStringBuilder替换代码高亮后文字颜色错位代码高亮器返回的Span位置和当前文本不一致先保证高亮用的是“当前文本”的索引别用上次的旧索引长消息弹出内存警告单条SpannableStringBuilder太大截断渲染、分页、异步渲染这些坑都不是什么底层难题但在真实场景里很容易被忽视尤其当你用现成的Markdown库时你根本看不到这些问题的源头。自己控制SpannableStringBuilder之后出了什么样的问题都能定位到具体是哪一段Spannable的操作导致的省去了很多“黑盒排查”的时间。6. 结尾的一些个人建议整个项目做下来我最大的体会是用SpannableStringBuilder做富文本渲染最核心的不是会用多少个Span而是建立一套可控的文本状态流转机制。文本是流式进入的、它暂时不完整我们要能停在“轻渲染”的状态消息结束后它才变成最终版我们再去“重渲染”。把这两个状态分开代码会清晰很多。实际测试中我还发现一个小技巧流式渲染时给TextView设置一个占位用的TypefaceSpan让代码块区域提前占据正确的行高能避免内容完整后整个气泡高度跳动。这个“高度缓冲”的细节很多人不知道但做出来效果非常自然。最后再说一个容易忽视的点SpannableStringBuilder虽然好用但它不是银弹。如果一条消息里放了几百个Span绘制性能就会明显下降。这时候优先考虑的是减少Span数量而不是无限叠加。比如同类样式用同一个Span实例去覆盖整个区间比每个token单独建一个Span要高效得多。小而美的渲染实现往往比堆功能的实现更可靠。