Android Compose版本的AI组件库最近总算在一个内部项目里憋出来了。之前一直有同行在群里问Compose生态里到底有没有能直接拿来接大模型能力的现成组件聊天的、流式输出的、图像生成的都行——结果不是半成品就是久不维护。这回趁着重构某跨平台系统的机会我把这类需求统一抽象成了一个库全程围绕Jetpack Compose来设计今天就把这里的整体思路、核心组件、接入细节和踩过的坑一次说清楚。这不是那种“能跑就行”的Demo合集而是一套真正面向实战的组件集合。它解决的核心问题很简单当你在做AI类Android应用时UI侧有大量重复且繁琐的工作——流式文本渲染、聊天列表的状态管理、图片生成结果的占位与加载、甚至提示词输入框的交互细节这些不该每个项目都从零写一遍。库把这些基础设施全部预制好了你只需要把模型API回调接进来把业务状态投喂进去剩下的事情交给组件。适合正在开发AI聊天助手、图像生成工具、智能客服、内部分析面板的Android开发者也适合想快速验证AI产品原型的团队。1. 为什么需要这样一个Compose AI组件库1.1 AI应用开发的现状痛点把大模型能力接进Android App逻辑上很简单拿到用户的输入拼一个请求发给服务端拿到返回后展示。但真正动手做的时候卡壳的地方几乎全在UI这一层。举个例子最常见的流式返回。服务端按token返回内容你需要让文本像打字机一样逐字逐句出现在屏幕上。这里面的难点不在于拼接字符串而在于怎样在不卡顿、不闪屏、不丢状态的情况下持续把半截文本渲染出来。如果用传统View体系你要考虑TextView的增量更新、Spannable的样式处理、RecyclerView的局部刷新。在Compose里这个问题的答案一开始也不是现成的需要自己管理StateFlow、处理重组频率、控制LazyColumn的滚动方向。这些都属于大量重复的工作每次写都是同样的逻辑抄来抄去还容易抄出bug。再比如聊天界面。说到聊天就绕不开消息结构、发送状态、加载指示、失败重试、自动滚动到底部这一整套逻辑。每做一个AI对话功能就要重写一遍你做三个应用就要维护三套几乎长得一样的聊天UI代码。如果你不在这一层做抽象团队里每个人的实现方式又不一样有的用Column硬怼有的用LazyColumn加animateScrollToItem有的干脆每来一条消息就重新渲染整个列表。这些方案各有各的问题交给一个统一组件去处理反而是更优选。还有图片生成类功能。模型出图一般要几秒到几十秒期间的加载状态怎么展示图片出来后怎么渐进式加载怎么支持放大缩小多张结果的网格布局怎么排版——这些同样具备很高的复用价值。说白了AI应用的UI层正在沉淀出一套属于自己的设计模式。谁能把它组件化谁就能在实际开发中省掉大把时间。这个库就是在这样的背景下诞生的。1.2 组件库的核心价值定位我给自己定的目标是只做AI功能相关的UI基础设施不碰具体业务实现。这意味着底层接的是哪家模型服务、走的什么协议、请求怎么封装全部通过接口抽象出来由应用层自己决定。库本身只关心组件怎么把状态渲染出来。因此这套组件库的价值主要体现在三方面。第一是统一交互模式。所有组件遵循同一套设计规范无论是聊天气泡、图片结果还是参数面板它们的加载状态、错误状态、空状态都有统一的视觉语言。这样不仅代码风格一致用户在不同页面上的操作习惯也是连贯的。第二是减少集成的重复劳动。给你一个开箱即用的组件内部已经处理好了键盘避让、输入框清空、滚动定位、消息节流这类细枝末节。接入时只需要专注业务逻辑不用再处理那些“看起来简单但总出事”的边界情况。第三是给团队沉淀提供抓手。有了这套组件之后新成员想做AI功能不需要去翻之前项目的历史代码直接看组件库的文档和示例就能上手。想要调整样式改主题参数即可而不是去改每个页面里的硬编码。这三点加起来实际上就是在帮你把AI应用的开发门槛整体降了一档。以前做一个AI聊天页可能要大半天现在花一个小时接通数据流就可以了。2. 组件库的核心能力与组件拆解2.1 对话式聊天组件从消息流到交互细节全包聊天组件在整个库里占的权重最大因为它几乎是所有AI应用的第一入口。这个组件内部封装了两个关键部分消息列表和输入栏。消息列表我直接基于LazyColumn做了二次封装核心思路是用一个SnapshotFlow监听消息列表的变化当新增消息时会自动滚动到底部。如果用户主动向上翻阅历史消息则暂停自动滚动避免正在看上文的时候被突如其来的滚动打断。这里的判断逻辑是列表状态记录lastVisibleItemIndex只有当最后可见项接近列表末尾时才触发自动滚动否则认为是用户处于阅读历史的状态。消息气泡本身设计成可组合的模板基础类型包含用户消息、AI消息、系统通知三类。每类消息都有独立的背景色、圆角、对齐方式和时间戳位置。AI消息支持富文本解析可以识别内容中的代码块、加粗文本和行内代码。这个解析功能我在实现时用了一套轻量的Markdown解析器没有引入特别重的依赖这样组件整体的体积还控制在可接受范围内。输入栏部分处理的事情更多。最基础的输入框、发送按钮就不提了关键是它内置了“多轮对话的上下文感知”状态槽位。你可以把当前会话的上下文标识丢给组件输入栏会根据会话状态自动切换成“思考中”“生成中”“出错可重试”三种模式。比如模型还在返回结果时输入框会置灰并显示一个停止按钮点击后可以向你的业务层发出取消信号终止当前请求。这个交互细节对AI产品来说非常重要用户等得不耐烦的时候给一个中止入口能显著降低焦虑感。2.2 流式文本渲染组件打字机效果也可以很稳流式文本渲染是AI组件库另一个核心模块我用一个独立的组件StreamTextView来实现。它的核心能力是接收一个持续发送字符串片段的Flow然后以打字机效果逐段呈现出来。这里有三个关键设计。第一是增量更新而不是全量替换。每次从Flow里拿到新数据时组件只绘制新增的那一段内容避免整个文本块的重组。我在底层用一个SnapshotStateListString来累积文本块并在绘制时按块拼接。这么做的好处是长文本场景下重组成本可控不会因为文字越长越卡。第二是支持中途中断。当用户暂停生成或者请求失败时组件会停止监听上游数据同时保留已经渲染出来的内容。如果业务层需要“重试”它可以从原始消息重新建立流式连接中间内容不用清空。这一点对网络不稳的移动场景太重要了断线一次不应该把整个回答抹掉。第三是文本效果可插拔。组件预留了一个TextEffect接口你可以传入光标闪烁效果、逐字浮现效果甚至是逐段淡入效果。默认内置了最常用的打字机光标其他人可以根据需要自定义。实际接入的时候你零售要做的就是把模型API的返回流转成这个组件认识的类型。比如你用OkHttp的Call做流式请求那就在回调里把每个Source读取到的内容emit到Flow中如果你用的是第三方SDK的SSE回调也只需要在回调里转发一下。组件不关心你的数据是从哪来的它只负责把它渲染好。2.3 图像生成结果展示组件加载、预览、保存一步到位图像生成的UI实现起来比聊天更繁琐因为状态周期更长。从用户点击生成到最终出图中间可能经过队列排队、模型推理、图片返回几个阶段每个阶段都需要不同的界面反馈。这个库里的图像组件把整个生命周期状态集成了进去Idle、Generating、Preview、Error。Generating状态下展示的是一个带动画的占位框内置了进度指示和取消按钮Preview状态下展示的是生成的图片网格支持捏合缩放、长按保存、点击查看大图。大图查看用了ModalBottomSheet来实现避免额外引入一个全屏Activity。组件接收的数据类型是ImageResult抽象类。它有两个子类RemoteImage表示网络图片组件内部自动加载并处理缓存LocalBitmap表示直接生成的本地位图适合那些diffusion模型已经下载到本地、可以直接拿到Bitmap的场景。这种设计让组件既支持云端API出图也支持端侧模型出图。多张图片结果的网格布局也做了适配。默认是两列网格间距通过参数可调。每张图片的宽高比可以不一致组件会自动做ContentScale.Crop裁剪保证网格整齐。如果你希望保留原始比例也可以传一个preserveAspectRatio参数组件会改用ContentScale.Fit并在网格中对齐。2.4 提示词输入与模型参数配置面板AI应用里还有个绕不开的场景输入提示词和调整模型参数。很多开源项目里这部分的UI都要临时写而且写得还很粗糙。库里整合了一个可折叠的参数配置面板包含温度、最大长度、Top-P、频率惩罚这些通用参数每一项都有Slider或Switch控件并且支持自定义取值范围。参数面板最大的好处是它的状态是单一数据源。所有的参数统一放在一个ModelConfigState中面板上的每次调整都会直接更新这个状态。接入者可以把整个状态对象传给底层请求封装发起请求时自动携带这些参数。这样你就不需要自己维护几个互相独立的mutableStateOf变量然后手动拼请求体。提示词输入框则做了两个增强。一是支持多行自动增高最高可以到五到六行超出后滚动。二是内置了“历史提示词”的快速插入功能。如果你做的是面向专业用户的工具类应用这个功能会特别实用——用户输入/就可以弹出历史记录列表选中一条直接插入。3. 组件库背后的关键设计决策和原理3.1 为什么所有的数据流都用Flow驱动我在设计这个库的时候几乎所有的组件都遵循同一个数据流转模型业务层产出Flow组件内部通过collectAsStateWithLifecycle收集并转成Compose状态。这不是随意的选择而是为了兼顾生命周期和背压。先说生命周期。collectAsStateWithLifecycle是Compose团队的官方推荐方案它会在ON_STOP时自动暂停收集在ON_START时恢复。这对AI场景尤为重要因为流式输出是持续高频的如果用户把应用切到后台你还在不断接收数据并触发重组不仅浪费电而且会造成大量无效计算。用了这个API之后组件自动具备前后台感知能力不需要接入者额外写生命周期代码。再说背压。大模型接口的返回速度通常快于UI的渲染速度尤其是当文本token特别小时一秒钟可能回调几十次。如果每次都直接触发重组列表就会产生严重的性能问题。我在流式文本组件内部加了一个简单的合并策略短时间内默认150毫秒到达的文本片段先累积起来到时间窗口结束时统一更新一次UI。这样既保证了打字机效果的流畅度又把重组频率控制在了每秒6-7次左右肉眼感知上几乎没有差异。3.2 长对话列表的懒加载与自动滚动策略聊天列表用LazyColumn是必然选择但光是用了LazyColumn不代表就稳了。AI对话列表有个特征消息可能特别长单条消息的内容可能超过一屏。这意味着LazyColumn对每条消息的测量开销会变大滚动的稳定性也需要额外关注。我做了三个层面的优化。第一每条消息的Modifier都加上stable标识让Compose编译器知道这条消息的结构在重组时不会频繁改变从而跳过不必要的重组。第二消息内容自身的状态提升到了ChatMessage数据类中组件内部用remember(message.id)来缓存关键子组件的实例避免滚动时重复创建。第三自动滚动使用scrollState.animateScrollToItem而不是直接scrollToItem前者有动画过渡不会让用户觉得内容是被瞬间拽走的。关于自动滚动还有一个细节当用户正在阅读时来了一条新消息到底该不该滚我的方案是记录当前列表第一个可见项的索引和偏移量。如果索引已经大于某一阈值比如总数的百分之八十判定为“用户基本在底部”自动滚动触发否则不触发只在消息栏上显示一个“有新消息”的小按钮点击后手动滚动到底部。这个交互设计借鉴了主流聊天App的做法实用性很高。3.3 可扩展的模型适配层怎么设计组件库还有一个隐藏能力模型适配层。UI组件不直接依赖任何具体模型的SDK而是通过一套接口与外部模型服务解耦。核心接口是AiService它定义了一个方法fun chatStream(request: ChatRequest): FlowChatResponse。任何模型服务不管是云端API、本地模型还是自己封装的中转服务只要实现了这个接口就能接入组件库的所有组件。为了让这个抽象足够灵活我额外定义了几个数据类。ChatRequest包含消息列表、模型参数、会话标识和停止标识ChatResponse是一个密封类分别表示增量文本、完整消息、错误信息和结束标记。组件内部根据这些类型来驱动UI状态机。比如收到Delta类型就追加文本收到Complete就关闭加载状态收到Error就显示错误提示并允许重试。这样的好处是显而易见的你可以先接一家模型服务做原型跑通之后再平滑切换到另一家。对产品经理来说这是A/B测试不同模型能力的理想状态对开发者来说也不用再为每个供应商单独开发一套UI逻辑。4. 从零上手实操接入步骤与代码示例4.1 添加依赖和基础配置假设你已经有一个Compose项目集成这个组件库的步骤很简单。先在模块的build.gradle里加上依赖dependencies { implementation(com.example:compose-ai-kit:1.0.0) }组件库要求的最低配置是minSdk 26和Compose BOM 2024.06.00以上版本。如果你正在用低版本Compose建议先升级否则会有部分API不兼容。网络权限别忘加。需要在AndroidManifest.xml里声明uses-permission android:nameandroid.permission.INTERNET /如果你的图像生成组件需要保存图片到相册还要额外加WRITE_EXTERNAL_STORAGEAndroid 9及以下或者使用MediaStore接口Android 10。组件内部已经封装好了保存逻辑运行时权限需要你在业务侧自己申请。完成这些之后就可以开始使用核心组件了。4.2 一分钟接入AI聊天页接入聊天组件最简写法如下Composable fun ChatScreen() { val service remember { MyAiService() } // 实现 AiService 接口 AiChatScreen( service service, conversationId conversation-001, modifier Modifier.fillMaxSize() ) }这个AiChatScreen会把消息列表、输入栏、参数面板、错误提示全部组装好。你只需要保证MyAiService正确实现了接口整个聊天页面的逻辑就全部跑通了。当然实际项目中你通常还需要接入自己的用户体系。消息列表的数据结构里包含了sender和senderId字段你可以根据业务填充。conversationId用于标识一组对话服务端需要按这个ID维护上下文但组件本身不参与。跑起来之后你会看到默认的聊天UI风格。它的配色跟随Material3主题如果你有定制需求见下一节的自定义主题方法。4.3 接入流式输出和图像组件如果你只想使用流式文本组件接入方式如下Composable fun StreamingResponseView(stream: FlowString) { StreamTextView( textStream stream, typingEffect TypingEffect.Cursor, modifier Modifier.padding(16.dp) ) }TypingEffect.Cursor会在文本末尾显示一个闪动的光标模拟正在输入的感觉。如果某些场景不需要这个效果用TypingEffect.None即可。图像组件接入也不复杂Composable fun ImageResultGrid() { val results: ListImageResult by viewModel.imageResults.collectAsState() AiImageGrid( results results, columns 2, onSaveImage { bitmap - saveToGallery(bitmap) } ) }AiImageGrid会自动处理每个图片项的加载状态和动画。你只需要在onSaveImage回调里实现落盘逻辑长按事件已经被组件消费掉了。4.4 主题定制让你的应用风格不被组件绑架组件库默认提供了一套清爽的主题但如果你用在已有的商业项目里大概率需要匹配现有风格。库里的所有组件都暴露了contentColor、backgroundColor、bubbleStyle之类的参数。举个例子聊天组件的样式定制可以这样做AiChatScreen( service service, bubbleStyle BubbleStyle( userBubbleColor Color(0xFF1E88E5), aiBubbleColor Color(0xFFF5F5F5), userTextColor Color.White, aiTextColor Color(0xFF212121), cornerRadius 18.dp ) )如果你对气泡的形状、阴影、时间戳位置都有特殊要求可以传入一个自定义的MessageBubble槽位函数完全替换默认实现。槽位设计参考了LazyColumn的itemContent思想给接入者留出了最大的自由度。需要提醒的是主题定制时要考虑无障碍对比度。默认配色已经满足无障碍标准但如果你改成浅色文字配浅色背景要自行确认对比度达标。5. 实战避坑指南稳定运行的经验5.1 流式渲染中的闪烁和卡顿怎么解决我在内部测试时遇到过几次流式渲染的意外情况明明数据源一直有输出UI却时不时闪一下或者干脆卡住不动。查来查去原因出在数据源类型上。如果你在业务层用的是MutableStateFlow那emit时如果值没有变化根本不会被收集方感知。比如每两个token之间拼接之后内容长度相同虽然概率很低但确实可能StateFlow会认为值没变跳过分发。解决方案有两个一是改用Channel作为数据源它天然支持连续事件流二是给StateFlow的状态加一个序号字段保证每次值都不同。闪烁问题的另一来源是列表项的key设计不当。我最初用item.content.hashCode()做LazyColumn的key但流式更新时内容哈希一直在变导致列表认为旧项被移除了新项被插入了于是出现闪烁。后来改成消息的唯一ID作为key内容更新时只重组内容部分不动列表结构问题直接消失。5.2 键盘弹出的动作避免遮挡输入栏聊天页面接入软键盘时最容易出的问题是输入框被键盘遮挡或者列表在键盘弹出后出现跳动。我在组件里用Modifier.imePadding()来处理键盘避让而不是传统的windowSoftInputMode调整。前者是Compose官方推荐的适配方式可以保证输入框始终悬浮在键盘上方同时不会把整个列表顶上去。它的原理是根据窗口的WindowInsets来自动调整内容区域的内边距这在enableEdgeToEdge模式下也能正常生效。但要注意imePadding()需要放在最外层布局上才有正确效果。如果你把AiChatScreen嵌在了一个已经手动设置过padding的容器中内边距计算会叠加导致键盘弹出时内容区域多出一截空白。排查这类问题的方法很简单临时把容器background改成明显颜色观察键盘弹出前后的区域变化即可。5.3 不同模型服务API差异的处理最后分享一个关于“适配不同模型”的实操经验。组件的AiService接口把数据流统一了但你在具体实现时仍要注意各家API风格的差异。比如有些服务的流式接口是“首批返回较慢后续很快”有些则是“开头快后续可能中途停顿”。这会影响组件显示“正在输入”状态时的等待时间。我的建议是不要在组件里写死等待时长而是把“等待开始输出”的超时逻辑交给AiService实现方拿捏。组件只负责展示业务层定义好的状态至于这个状态什么时候切换由请求封装决定。再比如错误类型。有的API会在流中断时返回一个错误码有的只会直接断掉连接。组件里定义了一个AiErrorType枚举包含NetworkError、RemoteServerError、ContentFiltered、Timeout四类。你在适配具体模型时把对方的错误信息映射到对应枚举即可。这样做的效果是用户侧展示的中文错误提示是统一的而不是各API返什么就展示什么。多模态场景类似。图像组件支持ImageResult抽象类之后云端API返回URL和本地模型返回Bitmap都能被同一个网格组件处理。只是要注意云端URL场景务必做好图片缓存否则快速滑动时网络请求会密集触发容易占满带宽。我在实际项目中已经用这套组件接了两个不同类型的大模型服务从纯文本对话到图片生成都有覆盖。期间最大的感受是UI组件这套东西看起来简单但真正做到“让业务方不用关心界面细节”还是很考验抽象能力的。踩过上面的坑之后现在新的AI功能集成基本就是写一个AiService实现类的事剩下的组件都在既定的轨道上运行。如果你也在规划AI应用建议先拿来试试早用早省心。