1. 从命令式到声明式,AI组件库为什么值得重做一遍
Android 原生开发这两年最大的变化,不是某个新框架的发布,而是 UI 构建思路的整体迁移。Jetpack Compose 从 2021 年稳定版发布到现在,已经从一个“尝鲜选项”变成了很多团队新项目的默认选择。我身边不少做 Android 的朋友,去年还在纠结要不要把老项目迁到 Compose,今年已经开始抱怨“怎么连个像样的 AI 对话组件都没有”。
这个抱怨其实很真实。大模型能力开放之后,几乎每个 App 都想塞一个智能助手进去——不管是客服问答、内容摘要、还是拍照识图。但真到动手写的时候你会发现,Android 端能直接拿来用的 AI 交互组件少得可怜。大部分团队的做法是:自己画一个聊天气泡、自己写一个流式打字机效果、自己处理输入框和键盘的联动、自己封装一个“正在思考”的加载态。这些东西单看都不难,但拼在一起就是两三周的工时,而且每个项目都要重来一遍。
所以当我看到“Android Compose 版本的 AI 组件库”这个方向时,第一反应是:终于有人把这块的重复劳动给收拢了。它要解决的核心问题很明确——把 AI 应用里那些高频出现的交互单元,做成 Compose 原生、开箱即用的组件。你不需要再关心气泡怎么画、流式文本怎么逐字渲染、多轮对话的状态怎么管理,直接调用组件、传入数据就行。
这篇文章适合两类人看。一类是正在做 Android + AI 功能、被 UI 细节拖慢进度的开发者;另一类是想了解 Compose 组件库设计思路、准备自己造轮子的同学。我会从整体设计、核心组件拆解、实操落地、踩坑排查几个角度,把这个组件库该有的样子讲清楚。需要说明的是,下面涉及的具体 API 命名和参数,是基于 Compose 生态常见实践做的合理设计推演,不是某个特定库的官方文档,你可以把它当成一份“如果我来做这个库”的完整方案来看。
2. 组件库的整体设计与技术选型思路
2.1 为什么是 Compose,而不是继续用 View 体系
先回答一个很多人会问的问题:AI 组件库为什么非要绑定 Compose?用传统的 View 体系加自定义 View 不行吗?
技术上当然行,但体验上会很别扭。AI 交互有一个非常典型的特征——状态变化极其频繁。流式输出的时候,文本内容可能每几十毫秒就变一次;思考状态会在“加载中”“正在输入”“已完成”之间来回切换;多轮对话的消息列表会不断追加。这种场景下,View 体系的命令式更新就是灾难:你要手动 findViewHolder、手动 notifyItemChanged、手动处理 DiffUtil,稍不注意就是闪烁和错位。
Compose 的声明式模型天然适配这种场景。你只需要描述“当前状态对应的 UI 长什么样”,剩下的重组、差量更新交给框架。流式文本来了新字符,你更新一个 State,UI 自动刷新,不用管哪个 View 需要 invalidate。这就是为什么 AI 类交互特别适合 Compose。
另一个原因是组件的可组合性。AI 界面往往不是孤立的,它要嵌在聊天页、嵌在侧边栏、嵌在底部弹窗里。Compose 的组件本身就是函数,嵌套和复用成本极低,一个AIChatPanel可以塞进任何布局,不需要考虑 ViewGroup 的层级限制。
2.2 组件库的边界:该封装什么,不该封装什么
做组件库最容易犯的错,是贪大求全。什么都想封,最后变成一个又重又难用的怪物。我在设计这类库的时候,会先划一条线:只封装“交互形态”,不封装“业务逻辑”。
具体来说,下面这些属于组件库该管的:
- 消息气泡的视觉呈现(用户气泡、AI 气泡、系统提示)
- 流式文本的逐字渲染与光标效果
- 输入区域的布局、发送按钮状态、多行输入
- 加载态、错误态、重试入口
- 消息列表的滚动与自动吸底
- 快捷指令、推荐问题的展示
而下面这些,组件库不该碰:
- 具体调用哪个大模型、怎么鉴权
- 对话历史怎么存储、怎么持久化
- 业务层的意图识别、路由分发
这条线划清楚之后,组件库就变成了一个纯粹的“展示层工具箱”。你给它数据,它给你界面;你给它回调,它告诉你用户点了什么。至于数据从哪来、点了之后干什么,那是业务层的事。这样的库才能被不同项目复用,而不是绑死在某个特定后端上。
2.3 模块划分与依赖策略
一个健康的 Compose 组件库,模块划分应该遵循“核心 + 扩展”的结构。核心模块只依赖 Compose 基础库和 Material 3,保证体积可控;扩展模块按需引入,比如 Markdown 渲染、代码高亮、图片加载这些重依赖,单独拆出去。
依赖策略上有个经验:尽量不引入第三方 UI 库。很多组件库为了省事,直接依赖某个流行的图片加载库或者动画库,结果使用者的项目里出现版本冲突,苦不堪言。正确的做法是定义接口,让使用者自己注入实现。比如图片加载,组件库只暴露一个ImageLoader接口,你用 Coil 也好、Glide 也好,自己传进来。
提示:组件库的依赖越少,被采用的门槛越低。每多一个强制依赖,就多一批被劝退的用户。
3. 核心组件拆解与关键实现细节
3.1 消息气泡:看似简单,细节最多
消息气泡是 AI 界面里最基础的单元,但它的细节远比想象中多。一个合格的气泡组件,至少要处理这几件事:
宽度约束。气泡不能无限宽,通常限制在屏幕宽度的 75% 到 85% 之间。太宽了阅读时视线跨度大,太窄了频繁换行。这个值建议做成可配置参数,因为不同场景需求不同——客服场景可以宽一点,代码展示场景可能要更宽。
圆角与尾巴。用户气泡和 AI 气泡的圆角方向通常相反,用户气泡右下角收窄,AI 气泡左下角收窄,形成视觉上的“指向”。Compose 里用RoundedCornerShape配合不同的 corner 参数就能实现,不需要自定义 Shape。
内容类型分发。气泡里可能是纯文本、可能是 Markdown、可能是图片、可能是代码块。这里推荐用when表达式做类型分发,而不是把所有渲染逻辑塞进一个函数。每种内容类型对应一个独立的 Composable,职责清晰,也方便使用者按需替换。
@Composable fun MessageBubble( message: AIMessage, modifier: Modifier = Modifier, maxWidthRatio: Float = 0.8f ) { val isUser = message.role == Role.USER val bubbleColor = if (isUser) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant val shape = if (isUser) { RoundedCornerShape(16.dp, 16.dp, 4.dp, 16.dp) } else { RoundedCornerShape(16.dp, 16.dp, 16.dp, 4.dp) } // 内容分发与渲染 }长按与选择。用户经常需要复制 AI 的回答,所以气泡要支持长按弹出菜单。Compose 里用combinedClickable处理长按,配合DropdownMenu做菜单。注意菜单的锚点位置要跟随气泡,别弹到屏幕外面去。
3.2 流式文本渲染:打字机效果的正确打开方式
流式输出是 AI 交互的灵魂,也是最容易做砸的地方。我见过不少实现,要么卡顿,要么光标乱跳,要么文本更新时整个列表都在重组。
先说原理。大模型返回的是 SSE 流,每个 chunk 可能是一个字、一个词、甚至半个词。你的任务是把这些 chunk 平滑地渲染出来。有两种常见策略:
策略一:来一个 chunk 就更新一次 State。简单直接,但如果 chunk 来得太密集(比如每秒几十个),会触发大量重组,低端机上直接掉帧。
策略二:缓冲 + 定时刷新。用一个队列缓存 chunk,然后以固定频率(比如每 16ms 或 32ms)从队列取字符更新 UI。这样重组频率可控,视觉上也更平滑。
我实测下来,策略二在低端机上的表现明显更好。具体做法是用LaunchedEffect起一个协程,循环从Channel里取数据,配合delay控制节奏。
@Composable fun StreamingText( stream: Flow<String>, modifier: Modifier = Modifier ) { var displayText by remember { mutableStateOf("") } LaunchedEffect(stream) { stream.collect { chunk -> displayText += chunk // 可选:加一个极短的 delay 让节奏更自然 } } Text(text = displayText, modifier = modifier) }光标效果。打字机通常需要一个闪烁的光标。Compose 里用rememberInfiniteTransition做透明度动画即可。注意光标只在流式进行中显示,流结束后要移除,否则会显得很怪。
自动滚动。新内容来了要自动滚到底部,但用户手动往上翻的时候不能强制拉回来。这里要监听列表的滚动状态,用一个标志位记录“用户是否在底部”,只有为 true 时才自动滚动。
注意:流式渲染时不要每次都重建整个消息列表。把正在流式输出的那条消息单独拎出来,用独立的 State 管理,避免整列表重组。
3.3 输入区域:键盘、多行、发送状态的联动
输入框看起来就是个TextField,但 AI 场景下的输入框有一堆特殊需求。
多行自适应高度。用户可能输入很长的内容,输入框要能自动长高,但到一定高度后停止增长并内部滚动。Compose 的BasicTextField配合heightIn就能实现,关键是监听文本行数变化。
发送按钮的状态。输入为空时按钮禁用,有内容时启用,流式输出中可能还要变成“停止生成”按钮。这个状态机要理清楚,别出现“明明有内容按钮却是灰的”这种 bug。
键盘与发送的联动。很多用户习惯按回车发送,但多行输入时回车应该是换行。这里要区分imeAction和keyboardActions,通常做法是:单行模式回车发送,多行模式回车换行、用按钮发送。
输入法遮挡。Compose 里用Modifier.imePadding()处理键盘遮挡,配合WindowInsets让输入框始终贴在键盘上方。这个在全面屏和折叠屏上尤其要注意,不同设备的 inset 值不一样。
3.4 状态管理:加载、错误、重试的统一抽象
AI 请求有三种结果:成功、失败、进行中。组件库要把这三种状态统一抽象出来,让使用者不用每次都写一堆 if-else。
我推荐用一个密封类来建模:
sealed interface AIResponseState { data object Idle : AIResponseState data object Loading : AIResponseState data class Streaming(val text: String) : AIResponseState data class Success(val text: String) : AIResponseState data class Error(val message: String, val retry: () -> Unit) : AIResponseState }组件根据状态渲染不同的 UI:Loading 显示骨架屏或跳动点,Streaming 显示打字机,Error 显示错误提示和重试按钮。这样业务层只需要维护一个 State,UI 自动跟着变。
错误态的设计有个细节:重试按钮要携带上下文。不能只是简单重发,要把失败的那次请求参数带上,否则用户点了重试发现问的问题变了,体验很差。
4. 从零到一:把组件库接进真实项目的完整流程
4.1 环境准备与依赖引入
假设你已经有一个 Compose 项目,接入组件库的第一步是确认基础环境。Compose 版本建议在 1.5 以上,Kotlin 版本 1.9 以上,这两个版本对协程和 Flow 的支持比较完善,流式渲染不容易出幺蛾子。
依赖引入有两种方式:如果组件库发布到了公共仓库,直接加一行 implementation 就行;如果是本地模块,用include引入。这里要提醒一句,组件库的minSdk要和主项目对齐,别出现主项目支持 21、组件库要求 24 这种尴尬情况。
引入之后先跑一个最简单的 Demo,确认组件能正常渲染。这一步别省,很多问题(比如主题冲突、资源冲突)在早期发现比后期好解决得多。
4.2 搭建一个最小可用的对话页面
我们来做一