Compose AI组件库实战:流式文本、工具调用与性能优化

📅 发布时间:2026/10/18 5:19:09
Compose AI组件库实战:流式文本、工具调用与性能优化
从去年开始我们组就一直被一个问题反复折磨Web端那套AI对话组件已经在多个项目里跑得很稳了但一到Android这边每次新项目还是得把聊天气泡、流式文本、Markdown渲染、工具调用卡片这些东西重新撸一遍。更难受的是不同项目撸出来的实现还不一样有的用RecyclerView硬搓有的在Fragment里塞了一堆回调维护成本高得离谱。后来我们实在受不了内部启动了一个代号叫“某AI组件库”的项目——专门为Android Compose打造的一套面向AI交互场景的组件集。这篇文章我会把这套组件库的设计思路、核心实现细节、性能取舍以及从Demo推到生产环境过程中踩过的坑一次性讲清楚。如果你是负责App内AI对话模块的开发者或者想在Compose项目里快速接入一套可扩展的AI聊天界面再或者只是好奇“AI组件库”到底和普通UI组件有什么区别这篇文章都值得看完。我不会只贴一堆API文档式的内容重点会放在“为什么这样设计”“这个坑是怎么踩进去的”这些文档里不写的东西上。1. 从Web端到Compose为什么要专门做一套AI组件库1.1 通用UI组件解决不了AI交互场景很多人第一反应是聊天界面不就是“一个列表加一个输入框”吗RecyclerView加EditText早就够用了何必要专门做一套组件库这个想法我一开始也有过但真正深入AI交互场景之后才发现事情没那么简单。普通聊天列表的数据是“静态”的——发一条消息服务端返回一条完整内容界面只需要一次性渲染。但AI对话的拿到的响应是**流式Streaming**的内容是一个token一个token陆续到达的UI需要跟着内容增长而持续更新。这本身不难难的是你既要保证每秒多次刷新UI不卡顿又要维持滚动位置不跳变还要在用户阅读过程中把Markdown格式逐步解析成可读的富文本。这三件事叠加在一起就不是一个普通List能优雅解决的了。再加上现在的AI应用普遍有“工具调用”也就是让模型决定要不要调某个API、传什么参数“思考过程展示”“引用来源标注”“重新生成答案”这类交互。这些东西在通用组件库里找不到每个团队都得自己造。我们组某开发者在Web端维护过成熟方案到Android这边对比了一圈现有的开源Compose项目发现有个共同的短板大多停留在聊天界面的皮相没有真正抽象出AI交互的状态模型。比如流式响应、中断、重试、工具状态机这些核心逻辑在很多库里和UI代码耦合在一起根本没法复用。我们做某AI组件库的起点就是先把这层状态模型抽干净。1.2 组件库的边界哪些该收进来哪些不该收启动之前我们拉上组里几个主力统一过一轮边界问题组件库到底管什么、不管什么。最后基本达成了共识管会话消息列表、消息气泡、流式文本渲染、Markdown展示、输入输入建议栏、工具调用卡片、思考过程折叠块、典型错误态与骨架屏。不管具体模型对接、网络库、本地存储、上下文管理。这里最重要的一条是我们坚持下来的“组件库不做模型提供方”。这意味着库里不绑定任何大模型API不内置网络请求只暴露数据模型和回调接口由业务方把模型流式输出的结果喂进来。这样设计的好处是立竿见影的——同一个组件库既能接在线大模型也能接私有化部署的模型甚至能接纯本地的规则模拟器做UI联调时特别好用。边界确定之后我们就开始定数据模型。消息对象拆成三个层级会话层Session、条目层Entry、内容块层Block。一个Entry可以包含多个Block比如一段文本加一张工具调用卡片再加一段最终答案这样复合型回答才展示得开。这部分是纯粹数据结构的定义和Compose本身关系不大但决定后面所有组件能不能稳定扩展。2. 核心组件逐个拆解从数据模型到UI表现2.1 会话消息列表与气泡的扩展设计消息列表看起来最简单实际要处理的问题一点都不少。首先我们否定了直接用普通Column挂所有消息的做法——消息多了之后性能会直线下降。我们基于LazyColumn实现会话列表但和普通用法有几个显著差异第一每个消息条目的Key不是数据库ID而是一个业务生成的稳定ID。流式输出过程中一个消息Entry的status会从Streaming切到Completed内容Block会不断增加。如果Key不稳定很容易出现条目被重组重建的情况轻则闪烁重则滚动位置漂移。我们规定Key由消息ID加内容版本号组成只有内容发生结构性变化时才变更。第二气泡样式不是通过一个布尔字段区分左右而是暴露了四个可扩展的插槽SlotHeaderContent、AvatarContent、MainContent、FooterContent。调用方可以自由往插槽里塞东西比如在Footer里放“复制、重新生成、点赞点踩”操作条。这样避免了我们为不同业务方定制气泡时反复改库代码。第三容纳“复合条目”。代码层面大概是这样的Composable fun MessageEntryItem( entry: AiEntry, modifier: Modifier Modifier, contentSlots: ContentSlots ContentSlots.Default ) { LazyColumn 里的 item(key stableKey(entry)) { Column(modifier.fillMaxWidth()) { entry.blocks.forEach { block - when (block) { is Block.Text - TextBlockView(block, contentSlots) is Block.ToolCall - ToolCallCard(block) is Block.Thinking - ThinkingCollapse(block) is Block.References - ReferenceStrip(block) } } } } }这种基于Block的拆分方式本质上和Web端把回答切成“各种类型节点”是一个思路。只要模型侧输出时按语义把内容块切好UI侧就能稳定渲染不用关心整段Markdown能不能一次性解析成功。2.2 流式文本渲染边生成边展示的节奏控制这是整个组件库最核心也最难啃的骨头。流式文本的UI难点在于模型每吐一段增量文本UI就要把Markdown重新解析一遍并更新显示同时不能打断用户正在阅读的位置。我们最终采用的方案是三层数据流水线原始层只保存从服务端收到的原始文本增量用StateFlowString维护。解析层在后台线程把当前累计文本按行切分逐块转换成带范围的Span样式列表。展示层用Text组件拿到解析后的AnnotatedString渲染。这里最反直觉的一点是不要在每收到一个token的时候立刻触发整段文本重新解析。我们一开始就踩过这个坑模型输出稍微长一点主线程就被解析任务拖垮掉帧严重。后来改成“节流合并”策略用SnapshotFlow收集Flow配合debounce时间戳判断把短时间内到达的多个token合并成一次刷新。刷新频率控制在每秒15到20次左右肉眼已经足够流畅CPU占用却降了一大截。解析本身也比想象中复杂。传统Markdown是整段解析但流式场景下文本是不完整的# 标题可能刚输送到#代码块围栏可能只有三个反引号还没来得及闭合。我们的解析器必须能容忍“半截语法”——解析失败时不能抛异常要把无法解析的部分降级成普通文本展示等下一轮增量到达后再纠正。这块用了一种简单的“增量段落缓存”机制只对新增文本对应的段落做解析已解析的段落缓存结果而不是从头到尾重新解析整篇Markdown。class StreamingMarkdownParser { // 增量解析只处理新的文本段复用旧段的解析结果 suspend fun parseIncremental(origin: String, cache: MutableMapInt, SpanWrap): AnnotatedString }2.3 输入栏与附件的交互细节输入栏看起来不如列表核心但它其实是用户和AI交互的“入口”体验好不好直接影响留存。我们做的输入组件叫AiInputBar除了常规的文本输入、发送按钮、清空按钮之外主要解决了几个容易被忽略的点多行输入与软键盘的协调。AI对话输入框通常要支持多行但直接允许无限增高会让键盘把消息列表挤没。我们的方案是设置最大高度为屏幕的36%左右超过之后内部滚动态而不是继续增高。同时配合imePadding()保证软键盘弹出后输入框和发送按钮始终可见。粘贴内容的智能预处理。用户经常会把报错日志、需求文档直接粘贴进来。组件里内置了一个轻量检测如果粘贴内容包含大量换行与缩进自动切换到“等宽字体代码段落”的段落样式如果内容里包含URL会提取出来以可点击的Tag形式展示。这样用户发送的是高质量、结构清晰的内容AI回答质量也会跟着提升。附件交互区。我们在输入框上方预留了一条附件区支持图片、文档、语音三类资源。这块和业务方约定只传Uri和MimeType组件不负责文件读取和上传避免组件库越界。实际使用下来这个减法很重要——不同项目的文件存储策略和上传链路完全不一样强行统一只会让组件库变成一个处处妥协的怪物。2.4 工具调用与推理过程的可视化卡片这一块是AI组件库区别于普通聊天界面的标志性组件。现在模型回答里面经常穿插“调用天气API”“查数据库”“执行一段代码”之类的动作。如果这些动作只是藏在背后用户看到的就是莫名其妙的延迟如果不展示又没法解释为什么AI答得这么慢。我们设计了ToolCallCard组件把工具调用抽象成一个轻量状态机Pending等待模型返回参数→Running正在执行→Success成功→Failed失败→Cancelled被用户中断。卡片根据状态展示不同的视觉效果Pending时候显示一个细小的加载条和工具图标Running时候显示执行时长Success时候可展开查看返回摘要Failed时候展示错误信息并提供“重试”按钮。状态机的驱动数据也走统一的AiEntry结构里面的ToolCallBlock保存工具名、参数JSON、状态码、耗时和返回摘要。这样UI层不关心具体工具是什么只负责把状态可视化。扩展性上我们也很满意——新接入一个工具只需要保证回调里能产生新的Block数据即可组件代码完全不用动。3. 状态与重组的实战取舍Compose下的性能防线3.1 状态容器的选择为什么没有全面用CollectionCompose项目里同时存在StateFlow、collectAsStateWithLifecycle、mutableStateOf之争已经快变成引战话题了。在实际做这个组件库的过程中我们没有迷信某一派而是定了几个自己的原则跨层共享的长生命周期状态比如整个会话的消息列表用StateFlow承载因为需要支持多订阅方比如列表、未读角标、输入栏状态同步。单组件内的私有状态比如某个卡片是否折叠直接用remember { mutableStateOf(...) }。绝不在ViewModel里放一个很大的List然后整体替换。更新一条消息也创建新列表的话Compose差量重组压力极大。我们用的方案是消息列表按EntryId做索引更新每次只更新受影响的条目。这里分享一个我们后来总结的小原则状态能不能被“局部化”比选用Flow还是State重要得多。如果状态一直挂在顶层不管用Flow还是State重组都会扫一大片。我们在迭代过程中把很多全局状态不停往下拆拆到“谁显示谁持有”的程度性能问题自然缓解了大半。3.2 重组的局部化控制与稳定性技巧Compose的性能和重组范围强相关。同样是状态变化可能只重组一个Text也可能拖上整个页面。我们的组件库在避免过度重组上面做了几件事第一给核心数据类全部加上Immutable注解。如果你的数据类里面有一个普通List但没有标注Compose编译器不会信任它的稳定性每次传参都可能导致更大范围的重组。我们在AiEntry、AiBlock、ToolCallState这些核心类型上都做了不可变化处理内部用List配合Immutable标记或者改用只读快照。第二remember配合lambda传参而不是传对象引用。比如工具调用卡片的“重试”按钮回调传入的应当是一个rememberUpdatedState包裹的lambda避免因为回调对象变化导致整个卡片区域重组。第三用derivedStateOf精简高频计算。滚动条是否显示、自动滚动是否开启这类状态依赖另一个高频状态如列表首项是否可见直接用derivedStateOf做转换避免在Composable内部做临时计算。3.3 长列表的懒加载与自动滚动策略AI对话列表里消息到达速度是突发的模型没说话前静默一开口就是连续几屏输出。这给LazyColumn的自动滚动提出了比普通聊天更高一档的要求。我们实现了一个称得上“克制”的自动滚动策略当用户位于底部附近距底小于120dp时新内容到达自动滚到底部。当用户已经往上翻看历史内容时新内容到达只更新“底部有一条新消息”的提示条绝不抢夺滚动位置。这里有个细节很多人会踩坑直接调用animateScrollToItem在每次新block到达时都会打断用户的滚动或阅读体验极差。我们的做法是维护一个shouldAutoScroll的Boolean状态只有它为true时才触发滚动而且用的是scrollToItem而不是动画——因为流式输出时滚动频率太高动画反而造成视觉跳动。列表性能上还有一招beyondViewportItemCount设置成3。AI消息里经常有图片或折叠卡片高度波动大多预载几个条目可以有效减少快速滚动时的白屏闪烁。4. 踩坑实录把AI组件库从Demo推到生产的过程4.1 富文本高亮测量问题与文本API的边界第一个让我们抓狂的坑来自代码块高亮。Chat场景的代码块占比非常高而且要求等宽字体、行号、关键字着色。我们一开始用的是先转AnnotatedString再塞进Text的方式但很快发现两个问题一是换行与缩进的Span范围经常错位。高亮颜色的Span是基于解析器给的offset设置的但如果上游文本里含有\r\n和\n混用offset就直接偏移了。排查出来的原因很无厘头服务端某条消息返回时包含了两种换行符解析器没有统一。后来我们在解析层入口做了统一换行符的清洗这个坑才彻底结束。二是长代码块折叠后的文本度量TextMeasurer缓存失效。折叠代码块时我们只显示前几行展开时显示全部。最初在展开瞬间能明显看到一次闪跳后来定位到是我们没有结合key把TextMeasurer的缓存分开。解决方式是在折叠态和展开态使用不同的List数据源保证两次渲染不会复用同一段测量缓存。4.2 Markdown解析在后台线程的三重校验流式文本的解析我们在前面说了要用后台线程但生产环境真正跑起来之后线程切换本身也带来了新麻烦——状态一致性。我们的数据流是网络回调把文本StateFlow更新后台线程基于最新文本解析解析结果再post回主线程。因为每次解析的是累计全文所以要求每一次post回来的结果必须对应“当时的最新文本”。如果前后两次更新间隔太近后一次post回来的解析结果可能覆盖掉更新的内容。排查链路是这样的第一次发现问题是部分消息的Markdown样式“晚一步”才正确。当时怀疑是解析慢后来打印了每次解析输入的文本哈希发现存在“解析结果为旧文本、展示层却是新文本”的情况。最终我们加了三重校验解析开始前记录文本版本号解析结束后再次检查版本号若已变化则丢弃本次结果主线程回传时校验展示层当前版本号防止覆盖。这套校验加上之后再没出现过样式和内容不一致的问题。这件事也让我明白了一个道理多数并发Bug不是难在并发本身而是难在状态没有语义化版本管理。4.3 连续动画在低端机上的卡顿与降级方案AI交互里动画特别多新消息飞入、Thinking块折叠旋转、工具卡片状态切换、自动滚动等这些叠加起来在低端机上非常容易掉帧。我们最开始在测试机上一切正常结果拿某品牌两千元档手机一跑连续输出答案时界面卡得不像话。后来逐个动画做了性能分析发现最大的罪魁祸首不是动画本身而是“动画和重组叠加”。以Thinking块折叠为例折叠动画的每个帧都在触发状态变化状态变化又导致整个条目重组重组时间超过16ms时动画就变成幻灯片。我们的降级方案分三层能不用动画的地方绝不用动画。比如消息条目的飞入效果改成透明度渐变去掉位移和缩放。动画只作用于graphicsLayer不作用于布局属性。改折叠高度时用一个固定的animateFloatAsState配合Modifier.graphicsLayer { alpha ... }而不是去改height。检测低端机时通过一个全局开关直接关闭非必要动画只保留加载指示器。这三层做完之后低端机上的体验终于回到了可接受范围。发布前我还专门写了个性能测试脚本反复空跑100条流式消息确认帧率平稳才敢放出去。5. 接入成本、版本规划与一些个人经验总结5.1 最低接入成本三行代码跑起来组件库发布前我们内部达成了一个硬指标新项目接入至少要能在10分钟内看到聊天界面。为此我们提供了一个极简入口setContent { AiChatTheme { AiChatScreen( state rememberAiSessionState(), onSend { text, attachments - viewModel.send(text, attachments) } ) } }只要业务方提供onSend回调内部就会把消息拼接、流式渲染、工具卡片展示全部托管起来。复杂的自定义可以通过我们暴露的各种ContentSlots完成。这个设计效果非常显著某跨平台项目组接进来的时候从建项目到跑通对话只花了一个下午件搭好骨架后续基本就是调调配色和插槽。5.2 后续规划与生态位思考当前版本已经覆盖了流式文本、Markdown展示、工具调用卡片、输入栏、思考折叠块这些核心场景但离“完整”还有不小的距离。我们接下来的几个方向都很明确多模态消息的深度支持不只是展示图片还要支持连续多图对比、音视频播放器内嵌等场景。会话历史与草稿恢复的UI状态还原解决App被系统回收后重新进入需要完整恢复会话渲染的问题。更精细的Markdown渲染扩展比如表格、数学公式、Mermaid图组件库内的轻量渲染方案还在评估。生态位上我们希望这套组件库能和主流的ViewModel方案、导航方案都保持松耦合业务方可以任意替换自己的架构层。不绑定全家桶才能让更多人愿意用起来。5.3 一点个人心得最后说点写代码之外的事情。做这套组件库最大的收获不是我写了多少个Composable而是让我重新理解了“组件抽象”这件事。AI交互场景的特殊性不在于某个组件难写而在于状态模型和UI表现高度耦合——流式、中断、重试、多模态、工具调用每一种新状态都会在UI层引发连锁反应。如果一开始没有把数据模型定义清楚后续每加一个功能都是给整个工程打补丁。如果你也想在自己的项目里做类似的组件库我给一个很朴素的建议先花两周时间把数据模型设计好不要急着写UI。AiEntry怎么设计、Block怎么分类、状态机怎么抽象这些想清楚了后面的Compose代码会写得飞快。反之如果一开始就埋头写气泡样式等模型侧输出格式一变你的组件库大概率要重写一半。另外一个小技巧在做流式渲染这类高频更新场景时建议从一开始就在UI层加上一个“帧率浮标”开发阶段随时能看掉帧情况。我们就是靠这个浮标发现了好几个只有在真机低端模式下才出现的性能问题如果只依赖开发者模式里的Profiler定位成本会高得多。