
最近半年做AI应用的团队越来越多但Android这边一直有个比较尴尬的状况后端接大模型能力不难难的是一套完整、好用、还能直接落地的客户端UI层。每次都要自己写加载态、流式文本渲染、上下文管理、请求防重……写来写去代码结构都差不多。于是我自己抽了一套基于Compose的AI组件库专门解决这件事情——把大模型交互相关的UI和逻辑封装成可以直接拖进布局的Composable组件。如果你正在做一个带对话功能的应用或者准备在现有项目里引入AI能力又或者单纯想看看Compose如何和流式数据、异步状态打交道这篇拆解应该能给你不少参考。这套组件库的思路很简单把“和AI聊天”这件小事拆成输入、渲染、管理三个层次输入层做智能输入框渲染层做流式Markdown文本管理层做对话状态机。每个部分都是独立组件整库以模块化方式组织。我在下面会从设计思路、核心组件实现、接入配置到踩坑实录一项项展开说明。1. 先看清楚问题AI交互场景到底难在哪1.1 传统集成方式的时间成本非常高老实说直接调大模型平台的API并不难难点在业务接入层。你要处理的核心问题至少有五个网络层超时与失败重试、流式响应的拼接与解析、对话历史的上下文管理、UI层的加载与错误状态、以及用户连续输入时防止重复提交。这五个问题每一个单拎出来都不致命但合在一起项目里就会出现大量重复代码。今天在聊天页写一份流式下载的逻辑明天在翻译页又要写一份几乎一样的后天换了一个模型平台底层协议变了所有页面全部要改一遍。1.2 Compose的声明式模型很适合这个场景Java和View体系下所有的状态变化都要手动findViewById然后改控件属性确实繁琐。但Compose不一样它是声明式UI界面是状态的函数状态变了UI自动重组。这对AI交互场景来说非常契合因为AI交互天然是“状态驱动”的。对话列表就是一个状态集合输入框的可用性由当前是否在请求中决定流式文本的内容来自后台返回的增量数据。这些状态层层传递Compose会在数据变化时自动更新对应区域的界面不需要开发者自己维护复杂的UI刷新逻辑。同时协程Flow和Compose的配合几乎是天作之合。大模型接口一般有两种模式一次性返回和流式返回。流式返回正好可以用Flow来建模用collectAsState收集到Compose状态里每一段token到达都会触发文本更新形成了天然适配的“流式UI”。1.3 组件库的定位不是封装API而是封装交互范式我在设计这套组件库的时候给自己定了一个原则不替开发者做模型选择不限定具体平台只封装交互范式和状态管理。这意味着底层模型SDK是可以通过接口任意替换的。今天用这个平台的SDK明天换另一个平台的UI层不用动。组件库提供的是“对话界面长什么样、交互怎么处理、状态如何流转”这一套标准答案而模型接口只是一层薄薄的适配器。这是我认为AI组件库价值最大的位置底层大模型技术迭代快各家API五花八门但“用户和AI对话”的交互模式是相对稳定的。把稳定的部分沉淀成组件把变化的部分做成可插拔接口业务方接入时只关心业务本身。2. 整体架构设计拆解状态模型、数据流与控制反转2.1 核心数据模型先行所有代码都从数据模型开始。对话场景的基础模型我定义了三个简洁但覆盖完整场景。// 定义消息的角色 enum class MessageRole { USER, ASSISTANT, SYSTEM } // 一条完整的对话消息 data class ChatMessage( val id: String, val role: MessageRole, val content: String, val timestamp: Long, val isError: Boolean false, val isStreaming: Boolean false ) // 模型请求的运行状态 sealed interface ChatState { data object Idle : ChatState data object Loading : ChatState data class Error(val message: String) : ChatState data class Success(val message: ChatMessage) : ChatState }这三个模型把对话世界里最基础的事实描述清楚了谁说了一句话(role)、说了什么(content)、当前请求处于哪个阶段(state)。重要的是我单独用isStreaming标记消息是否处于流式接收中。这个字段看起来不起眼但实际很有用——它决定了UI是否显示打字光标动画也决定了消息内容是否可以继续更新。早期版本没有这个标记导致消息一进入列表就被当成完整消息渲染后续流式更新时列表状态混乱后来加了这个字段之后问题迎刃而解。2.2 单向数据流与ViewModel的承载方式组件库没有用简化到极致的“一个函数搞定一切”的设计因为对话场景真的需要状态管理的支撑。我采用的架构是标准单向数据流UI事件(用户输入、点击重试) - ViewModel处理 - 调用仓库接口 - 模型SDK - 结果通过Flow流回 - UI状态更新 - 重组过程不复杂但稳定。所有状态都存放在ViewModel中通过StateFlow暴露UI层只做“发事件、收状态”这两件事不直接触碰数据源。class ChatViewModel( private val engine: ChatEngine, private val historyStore: ChatHistoryStore ) : ViewModel() { private val _messages MutableStateFlowListChatMessage(emptyList()) val messages: StateFlowListChatMessage _messages.asStateFlow() private val _chatState MutableStateFlowChatState(ChatState.Idle) val chatState: StateFlowChatState _chatState.asStateFlow() fun sendMessage(content: String) { val userMessage ChatMessage( id UUID.randomUUID().toString(), role MessageRole.USER, content content, timestamp System.currentTimeMillis() ) _messages.update { it userMessage } viewModelScope.launch { _chatState.value ChatState.Loading try { val assistantMessage ChatMessage( id UUID.randomUUID().toString(), role MessageRole.ASSISTANT, content , timestamp System.currentTimeMillis(), isStreaming true ) _messages.update { it assistantMessage } engine.streamChat( messages historyStore.buildContext(_messages.value), onDelta { delta - _messages.update { list - val index list.indexOfFirst { it.id assistantMessage.id } if (index 0) { val updated list[index].copy( content list[index].content delta, isStreaming true ) list.toMutableList().also { it[index] updated } } else list } } ) _messages.update { list - val index list.indexOfFirst { it.id assistantMessage.id } if (index 0) { list.toMutableList().also { it[index] it[index].copy(isStreaming false) } } else list } _chatState.value ChatState.Idle } catch (e: Exception) { _chatState.value ChatState.Error(e.message ?: 请求失败) } } } }这段是组件库的核心骨架。它把一次对话请求的全流程管理起来了追加用户消息、创建空的助手消息、边接收增量边更新消息内容、结束后关闭流式标记。这里有个细节值得注意更新消息时始终坚持不可变数据更新每次都是copy出新的列表而不是直接修改原对象。这套做法配合Compose的状态检测非常丝滑列表更新会精准触发重组不会出现“改了数据但界面没刷新”的诡异Bug。2.3 模型引擎适配层接口驱动为了让组件库不绑定任何具体大模型平台我抽象了一个非常薄的接口叫ChatEngineinterface ChatEngine { fun streamChat( messages: ListChatMessage, onDelta: (String) - Unit, onFinish: () - Unit, onError: (Throwable) - Unit ) }接入方只要实现这个接口把SDK调用逻辑写在streamChat实现里在onDelta回调里按token把文本交出来即可。组件库内部完全不知道也不关心底层用的是哪个模型这种控制反转让组件库和业务彻底解耦。我还额外做了一个仓库层的ChatHistoryStore接口专门负责历史会话的记录与读取。有的团队用本地数据库存有的用内存存储有的直接只把最近二十条消息扔给模型做上下文窗口——无论哪种组件库只和接口打交道。3. 核心组件实操实现输入、流式文本与对话列表3.1 智能输入框组件AIInputBar输入框是所有对话信息的入口它的体验直接决定整个工具的上手感。我的AIInputBar组件做了四件事多行自适应高度、请求期间禁用发送、ShiftEnter换行与Enter发送、输入为空禁用按钮。Composable fun AIInputBar( enabled: Boolean, onSend: (String) - Unit, modifier: Modifier Modifier ) { var inputText by remember { mutableStateOf() } val isSendEnabled enabled inputText.isNotBlank() Surface( modifier modifier.fillMaxWidth(), tonalElevation 3.dp ) { Row( modifier Modifier.padding(horizontal 12.dp, vertical 8.dp), verticalAlignment Alignment.Bottom ) { BasicTextField( value inputText, onValueChange { inputText it }, modifier Modifier .weight(1f) .padding(vertical 8.dp), textStyle TextStyle(fontSize 15.sp), maxLines 5 ) Spacer(modifier Modifier.width(8.dp)) FilledIconButton( onClick { if (isSendEnabled) { onSend(inputText.trim()) inputText } }, enabled isSendEnabled ) { Icon( imageVector Icons.AutoMirrored.Filled.Send, contentDescription 发送 ) } } } }这里有一个比较容易被忽视的点发送按钮的enabled状态必须用DerivedState或简单表达式计算。如果每次输入都手动触发按钮状态更新会多出很多没必要的重组。Compose在计算isSendEnabled时是基于inputText的变化自动派生的这个开销很小但如果你把它单独塞进某个mutableState并手动同步反而容易出同步遗漏问题。3.2 流式Markdown文本组件StreamMarkdownText流式渲染是AI组件库最体现功底的部分。大模型返回的内容通常包含Markdown格式标题、代码块、表格、加粗如果直接以全文本方式去渲染一方面阅读体验差另一方面频繁更新会引发性能问题。我的做法是采用增量文本渲染策略。不直接把整个字符串塞给Markdown解析器而是维护一个文本状态在onDelta到达时拼接并触发一次轻量重组。同时只有当内容长度有变化、或者流式结束后才触发最终完整渲染。细节上做了两个优化流式过程中保留打字光标指示符作为流的视觉效果结束标记变为false后移除光标并完成最终排版。Composable fun StreamMarkdownText( content: String, isStreaming: Boolean, modifier: Modifier Modifier ) { val state rememberMarkdownState(content, isStreaming) MarkdownContent( state state, modifier modifier ) }这里的MarkdownContent是封装好的渲染层支持常见标准Markdown语法。由于在组件库底层我直接用了一套开源的Markdown渲染方案再在外部做了一层自适应处理。请记住渲染层把重心放在“所见即所得”的稳定性上不支持那些花哨的扩展语法这对流式渲染的稳定很重要。3.3 AI对话列表组件LazyColumn与自动滚动对话列表本质上是一个需要持续更新、还要自动滚动到底部的长列表这在Compose里用LazyColumn承担。我针对对话场景做了一个关键优化自动滚动策略。首先新消息到达时列表自动滑到底部其次用户主动向上翻阅历史时不要强制拉回底部最后流式更新时只在用户处于底部附近才继续跟随。Composable fun AIChatList( messages: ListChatMessage, modifier: Modifier Modifier ) { val listState rememberLazyListState() val coroutineScope rememberCoroutineScope() LaunchedEffect(messages.size) { if (messages.isNotEmpty()) { coroutineScope.launch { listState.animateScrollToItem(messages.size - 1) } } } LazyColumn( state listState, modifier modifier, contentPadding PaddingValues(16.dp), verticalArrangement Arrangement.spacedBy(12.dp) ) { items(messages, key { it.id }) { message - MessageBubble(message) } } }这里用了两个关键参数。第一个是items的key参数它让LazyColumn能够精确追踪每条消息的身份标识而不是靠索引这直接决定了流式更新时是否会出现列表错乱。第二个是contentPadding与Arrangement.spacedBy组合让消息气泡之间留出呼吸感不会贴在一起。由于消息数据很多大模型动辄回几百上千字所以LazyColumn是复用组件整体渲染性能通常没有问题。唯一的隐患是流式更新时每条token都触发全列表重组。我在实际使用中加了item级的稳定key再配合Compose的Diffing机制实际效果一路流畅这也是组件化方案给到的隐藏红利。4. 集成接入流程与关键配置细节4.1 三步接入从依赖到可用组件库的使用方式非常简单总共就三步。但每一部分都要做好细节不然动不动就踩坑。第一步引入依赖。因为是自有组件库我没有走中央仓库发布用的是本地Maven仓库方式集成团队在settings.gradle里加仓库地址模块里加一行依赖即可。implementation(com.example:ai-compose-kit:1.0.0)第二步初始化。在Application入口处配置模型Adapter。组件库设计上不持有全局配置而是要求接入方注入一个ChatEngine的实例这样模型API密钥、域名、超时策略这些敏感逻辑都留在业务侧组件库完全感知不到。class MyApplication : Application() { override fun onCreate() { super.onCreate() ChatKit.init( engine MyChatEngine( apiKey BuildConfig.API_KEY, modelName default-large-model ) ) } }第三步在页面里组装组件。普通聊天页面甚至只需要一个Box布局把AIChatList和AIInputBar放进去再在ViewModel里处理一下消息流转即可。4.2 Manifest配置里的三个坑第一个是网络权限。本地调试时如果模型接口是HTTP地址Android默认禁止明文流量需要在Manifest里配置usesCleartextTraffic或networkSecurityConfig。这个在线上通常用HTTPS没啥问题但团队内网联调经常在这里卡住。第二个是后台任务。如果App在后台时还想让流式请求继续需要确认是否用了前台服务如果是纯前台交互基本不需要额外处理。第三个是混淆规则。组件库内部涉及的序列化、反射类要保持默认否则打Release包后消息转换会莫名失败。我在项目里给组件库的keep规则单独建了一个文件防止后续业务团队盲目裁剪导致线上崩溃。4.3 参数调优从超时到上下文窗口接入组件库的时候有几个参数直接影响大模型返回的体验。第一是连接超时。我将连接超时设为15秒、读取超时设为60秒。因为大模型首token往往需要几秒甚至更久读取超时不能按常规的5秒处理否则长文本生成一半就中断。第二是最大生成长度。这个要按产品场景设置一般对话场景用1024到2048个token如果想要输出长文则放大到4096。但注意token数越大响应时间越长不要盲目加大。第三是温度系数。对话场景我一般设0.7左右代码生成场景设0.2左右。温度越高随机性越大这属于模型侧的偏好参数组件库并不强行接管但通过ChatEngine实现时可以自由透传。5. 实战问题与排查方案那些实际踩过的坑5.1 流式输出卡顿怎么定位组件库做完第一版后实测发现流式输出到两三百字时界面开始掉帧。排查后定位到两个元凶一个是每次增量更新消息列表时整条列表都要参与重组另一个是Markdown渲染器在文本增长时重复解析全量内容。解决方式列表项内容加stable key是基础同时用derivedStateOf对滚动位置做降频处理用户不在底部时不触发跟随滚动。渲染器换了增量解析模式只在流式结束后做一次完整Markdown解析流式中仅展示纯文本内容。修正后长文本输出基本稳定在60帧线附近。5.2 长对话内存膨胀与OOM风险另一个严重的问题是对话无限累积。连续二十轮以上长对话后消息列表和上下文窗口体积变大内存、计算开销都涨得很明显部分低配测试机上出现过OOM征兆。解决措施是在ViewModel里加了一层滑动窗口裁剪保留最近三十条消息作为模型上下文同时列表只缓存当前可见区域附近的若干条。此外图片消息、文件引用这类大对象也要做好独立存储避免全部塞进内存模型。5.3 组件之间状态残留导致的消息串线这种问题比较隐蔽。多个页面复用了同一个ViewModel后上一个页面的对话状态被带到新页面。后来排查发现是ViewModel的创建范围没控制好每次进入聊天页都拿到了一个复用实例。解决办法很简单ViewModel按页面作用域创建在Navigation中保证每进入一次聊天页面都产生一个新的ChatViewModel退出时自动清理。另外凡是和业务强相关的数据不要用全局单例持有。5.4 网络异常场景的体验优化模型接口不稳定是常态尤其是流式接口中途断连。最初只是弹出错误提示但用户在长对话中很反感被打断。后续改成了断线自动静默重试一次还是不行就在原消息下方给一个单独的重试按钮。这个设计放在组件库里效果很好让异常状态有明确的恢复路径而不是让用户对着一条断掉的回复发呆。重试按钮的点击事件以回调形式暴露给上层由业务决定重新请求或删除该消息。6. 可扩展方向从对话到多模态与生态化6.1 语音输入与输出组件库第一版只做文本交互但AI应用的发展显然不会停在打字上。语音输入组件可以封装成AIInputBar旁边的麦克风按钮点击后拉起录音并转文字再把文字内容填入输入框。语音输出则可以封装成消息气泡内嵌的播放器。如果团队做的是语音优先的场景组件库还可以提供语音状态机让“录音中—转写中—等待大模型回答—语音播放”的整个链路统一管理。6.2 多模态消息支持当前ChatMessage的content是纯字符串面向未来肯定要扩展为Content块支持图片、文件、卡片等多种形态。我在设计模型时预留了扩展位后续可以新增mediaUrl字段或者contentType枚举而不需要改动现有接口。一旦支持了图片消息多模态组件的调用面就被打开了翻译图片、识别截图、生成配图等功能都能在整套组件库的框架下自然生长出来不需要为每个能力另起炉灶。6.3 可插拔模型引擎的进阶玩法最让人兴奋的是模型适配层开放之后带来的可能性。因为组件库只认ChatEngine接口团队内可以针对不同场景注册不同的模型实现代码问答场景注册代码模型创作场景注册创意模型客服场景注册合规模型。页面层不需要知道具体是哪个模型在响应换个引擎就像换一个后台数据源UI和交互完全通用。这就是组件化的价值模型换得再勤交互框架稳定业务就不受底层波动影响。使用组件库这段时间我自己最大的体感是AI应用和传统应用的研发模式完全不一样它的核心在于“不确定性”管理。消息何时回来、回多长、格式什么样都是不确定的如果每一次都把不确定性重复处理一遍团队迟早会崩溃。把整套交互框架沉淀成组件把不确定的部分收敛在适配层里研发效率翻倍只是最表面的收益更深的是整个团队对AI类需求的评估、排期、估点都变得可预测了。如果你也在做AI方向的Android应用这套思路值得一试。