☰
Android Compose AI组件库:解决大模型接入痛点的实践指南
2026/10/20 14:37:01 网站建设 项目流程

如果你最近接手过把大模型能力塞进 Android App 的需求,大概率能体会这种憋屈:AI 本身没多复杂,胶水代码却写了一堆。调语音识别要维护录音状态,调文字生成要接流式输出,调图像理解又要处理图片选择、权限申请、Loading、错误重试。每个功能单独拎出来都能跑,凑在一起,代码就像被猫玩过的线团。最近我把手上几个项目里的 AI 接入逻辑抽出来,整理成了一套 Android Compose 版本的 AI 组件库——常见 AI 场景直接封装成 Composable 组件,业务侧要什么能力就按一个组件,传参数、收回调,内部细节不用管。

这套东西不解决模型训练问题,也不管你接哪家的模型服务,它只解决接入层的脏活累活:统一不同 SDK 的接口差异、规范异步状态、把“大模型一个字一个字往外吐”这种特殊数据流接到 Compose 的声明式 UI 上。不管你是刚开始接触 Compose 的新手,还是已经在生产环境里跑过多个 Compose 应用的开发者,只要 App 准备接 AI 能力,下面关于状态模型、流式输出、生命周期管理的思路,应该都能帮你省不少排查时间。接下来我会从设计思路、实操接入、底层原理到避坑经验,把这个组件库掰开揉碎讲一遍。

1. 为什么 AI 能力接入 Android 一直这么痛

1.1 传统 AI 接入的三座大山

这三座大山,第一座叫 SDK 差异。聊天模型、语音识别、图像理解,大概率来自不同服务商,每个 SDK 的初始化方式、调用入口、回调模型都不一样。A 家的对话接口是 sendMessage,B 家的语音接口是 startRecognizer,C 家的图像分析又要单独初始化一个 client。业务层为了接这些能力,往往要写一堆适配代码,一旦换服务商,页面层逻辑全部跟着改。更麻烦的是,这些 SDK 的回调线程模型还不统一,有的回主线程,有的回子线程,你还要在每个回调里手动切线程。

第二座山是异步状态管理。AI 请求天然是异步的,从点击按钮开始,要经历 loading、连接中、首字到达、流式输出中、结束、失败、重试,每一个状态都要在 UI 上有对应表现。传统写法是每个页面维护几个 Boolean 和错误对象,然后手动刷新。页面少还能忍,页面一多,同一套状态逻辑被复制十几次,漏改一个状态,就是线上 bug。我记得有个页面就是忘记把 loading 置回 false,结果用户点击生成之后,按钮一直转圈,只能杀进程。

第三座山是 UI 和 AI 数据的割裂。回调回到主线程之后,要先手动拼字符串、再 setState 或者 notifyDataSetChanged,数据和视图之间没有直接映射。Compose 里虽然能用 mutableStateOf 撑一下,但流式输出、断点续传、错误重试这些场景,光靠零散的 mutableStateOf 根本顶不住,状态一多就乱。AI 交互的中间态比普通网络请求多得多,如果没有一个统一的状态模型兜底,每个页面都在用不同的方式处理“正在进行”和“已经结束”,维护成本会持续上升。

1.2 为什么是 Compose 来解这道题

Compose 的声明式模型恰好和 AI 异步数据流对上了。UI = f(state),AI 返回什么数据,就往 state 里塞什么,Compose 自动完成重组更新界面。开发者不需要再手动操作 view,只需要维护状态本身。这个模型对 AI 场景尤其友好,因为 AI 交互的中间状态特别多,用声明式映射天然比命令式手改省心。比如流式输出,你只要把当前已生成的文本放进 state,UI 就会自动刷新成打字机效果,不用再自己拼接字符串之后手动 setText。

第二个原因是组合能力。Composable 函数本身就是最好的组件化载体,一个 AI 能力封装成一个 Composable,页面上像搭积木一样组合。用传统 View 写组件也不是不行,但 Compose 的职责单一、参数透明、可组合性强,生态也明显在往这边走。现在新开的项目基本都是纯 Compose,与其在传统 View 体系里维护两套组件,不如把 AI 能力统一封装在 Compose 这一层。业务侧页面布局时可以随意插入、嵌套、控制显隐,灵活性比自定义 View 高一个数量级。

第三个原因是生命周期绑定。Compose 组件可以感知组合的生命周期,DisposableEffect 可以在组件退出时自动清理协程和监听,这对 AI 请求这种长耗时、易泄漏的操作至关重要。回调用完不注销导致内存泄漏的问题,在 Compose 组件化封装里可以从框架层面杜绝。整体看下来,Compose 作为 AI 组件库的载体,不是因为它“新”,而是因为它的状态模型、组合能力和生命周期机制,跟 AI 接入场景的匹配度实在太高了。

2. 整体设计:从一张接口到一堆组件

2.1 四层架构,各管一段

整套组件库我拆成了四层:Provider 层、状态层、组件层、工具层。最底下是 Provider 层,它定义一个统一接口 AIProvider,把聊天、语音、图像三类能力抽象成方法。不同服务商的 SDK 写各自的 Adapter 实现这个接口,对上层完全屏蔽差异。当初定这个设计的时候,我反复提醒自己一件事:不要把某个具体服务商的 SDK 类型泄漏到组件层,否则以后换厂商就要动组件代码。

分层核心职责典型内容
Provider 层定义统一能力接口聊天对话、语音转写、图像分析
状态层承载 AI 请求状态AiUiState、StateFlow、Channel
组件层对业务暴露 ComposableTextGenCard、VoiceTranscribeButton、ImageAnalyzeCard
工具层权限申请、错误处理、重试麦克风权限、网络异常、指数退避

统一接口大概长这样:

interface AIProvider { fun observeChat(prompt: String, config: AiConfig): Flow<String> fun transcribe(audioFile: File): Flow<String> fun analyzeImage(image: ImageData, prompt: String): Flow<String> }

状态层是整个库的核心。每个组件内部持有一个 StateFlow,承载 Idle、Loading、Streaming、Success、Error 五个状态。组件层只做一件事:把 StateFlow 映射为 UI。工具层处理权限和通用异常,这样业务调用方拿到的组件参数里没有权限和错误处理的噪音。四层之间只允许向下依赖,组件层不能直接触碰具体 SDK 类型,这个约束保证了未来扩展新 AI 能力时不用推翻重来。

2.2 状态模型怎么定才不翻车

状态模型我这里用的是 sealed interface。Idle 表示还没开始,Loading 表示请求中,Streaming 表示流式输出中,Success 表示正常结束,Error 表示失败。用 sealed class 而不是多个 Boolean,是因为 AI 请求的中间态是互斥的,用多个 Boolean 描述互斥状态,组合一多就产生非法状态,比如 loading 和 success 同时为 true,这种 bug 在传统写法里太常见了。

sealed interface AiUiState { data object Idle : AiUiState data object Loading : AiUiState data class Streaming(val text: String) : AiUiState data class Success(val text: String) : AiUiState data class Error(val message: String, val retryable: Boolean) : AiUiState }

设计接口之前先定状态模型,这件事我吃过亏。最早我直接在每个组件里写 mutableStateOf,结果 A 组件用 Boolean,B 组件用枚举,C 组件直接塞字符串,后面做统一错误处理的时候对不上号,返工改了一整天。所以组件库开放出来之前,内部所有组件都统一套这一个 sealed interface,外部就算自定义组件,也建议用同一套。

事件和状态要分开。状态用 StateFlow 存,适合 UI 读取;一次性事件比如“转写完成,请发送”“分析结果已保存”,用 Channel 或者 SharedFlow 发,避免事件被重组重复消费。这样设计之后,组件内部状态切换和外部事件消费各走各的通道,互不干扰,排查问题也清晰很多。

2.3 流式输出与 UI 渲染的配合

流式输出是大模型聊天最常见的形态。服务端不是一次性返回整段文本,而是分多次 token 送回。组件内部要做的,是把多次回调累计成一个完整文本,再不断刷新 UI,形成打字机效果。这个逻辑看起来简单,实际写起来有三个细节:回调转换、生命周期收集、增量更新。

回调转换用 callbackFlow,把服务商 SDK 监听器包装成 Flow。生命周期收集用 collectAsStateWithLifecycle 而不是 collectAsState,因为前者能在应用退到后台时暂停收集、回前台时恢复,省电省内存。增量更新是性能关键:每来一个 token 就整体重置文本,会导致整个 Text 组件和下方内容全部重组,正确做法是只在外部暴露稳定的不可变 state,然后让文本组件用 remember 配合 key 做局部更新。

3. 上手实操:三个高频 AI 组件接入实录

3.1 初始化与全局配置

组件库使用上不复杂,先加依赖。

dependencies { implementation("io.github.xxx:compose-ai-kit:1.0.0") }

项目 Application 里做初始化:

class App : Application() { override fun onCreate() { super.onCreate() AiKit.init(this) AiKit.registerProvider(ChatProvider()) AiKit.registerProvider(VoiceProvider()) AiKit.registerProvider(ImageProvider()) AiKit.setDefaultConfig( AiConfig( temperature = 0.7f, maxTokens = 2048, enableStream = true ) ) } }

init 里面做三件事:保存 ApplicationContext 用于权限和资源获取、初始化全局异常监听、注册默认 Provider。每个 Provider 对应一类 AI 能力,如果项目只需要聊天,只注册一个就够了。全局配置里的 temperature 这些参数,在单个组件里也能覆盖。组件方法的参数优先级是组件参数大于全局配置,这样不同页面可以对同一个模型调不同温度。

关于依赖版本和 minSdk,组件库要求 App 本身 minSdk 不低于 23。这个是录音和图片选择权限模型的硬门槛,太低了光运行时权限就够折腾。如果你的项目还在用 minSdk 21,建议先评估能不能提上来,不能的话也可以用,但麦克风权限部分要走兼容分支,这属于接入前就要确认的约束。

3.2 文字生成组件:TextGenCard

聊天生成是最常用的场景,组件库提供了 TextGenCard,传 prompt 就能用:

TextGenCard( prompt = "用三句话介绍 Jetpack Compose", model = AiModel.Chat, temperature = 0.7f, onSuccess = { fullText -> viewModel.saveHistory(fullText) }, onError = { e -> log.e("AI", e.message.toString()) } )
参数类型说明默认值
promptString输入的提示词必填
modelAiModel使用的模型类型Chat
temperatureFloat随机性,越高越发散0.7
maxTokensInt最大生成 token 数2048
enableStreamBoolean是否流式输出true
onTokenUpdate((String) -> Unit)?每来一个 token 触发null
onSuccess((String) -> Unit)?完整文本回调null
onError((Throwable) -> Unit)?错误回调null
modifierModifier外部样式默认

组件内部会自己维护 Loading 和 Streaming 状态,UI 默认是一个标题、一段内容区、一个生成按钮。点击按钮之后进入 Loading,首字到达后切到 Streaming,文本不断刷新。如果你要在自己的页面上完全自定义 UI,组件也支持把内部 UI 换成自定义 slot,这个后面再讲。

第一次用的时候容易有个误区:onSuccess 和 onTokenUpdate 都会拿到文本,onTokenUpdate 是实时增量,onSuccess 是最终全文,业务侧需要做收藏、记录等操作建议用 onSuccess,需要实时展示建议用 onTokenUpdate。两个都监听也没问题,但别在 onTokenUpdate 里做数据库写入,流式输出每帧都可能回调,写库会非常频繁。我见过一个同事把保存历史逻辑放在 onTokenUpdate 里,结果生成一句话存了几十次数据库,直接卡到 ANR。

我在第一版组件里顺手把这个组件做成了非流式,也就是等完整文本回来再一次性显示,用户反馈等待时间太长。后来改成流式之后,体验好了不少,所以默认配置里 enableStream = true。如果你的使用场景是生成结果直接落库、不需要用户等待,可以关掉流式减少回调次数。

3.3 语音转写组件:VoiceTranscribeButton

语音转写组件设计成了一个 button 形态,点一下开始录音,再点一下结束并返回文字:

VoiceTranscribeButton( language = "zh-CN", onResult = { text -> viewModel.updateInput(text) }, onError = { e -> showToast(e.message) } )

组件内部处理了麦克风权限申请、录音状态切换、录音文件缓存、转写任务提交。权限申请默认用 rememberLauncherForActivityResult 在组件内部完成,业务方不用写权限回调。录音过程中按钮会切换成红色,并且显示录音时长,这个时长是内部计时器维护的。期间如果用户切到后台,录音会暂停并提示,防止录到一堆静音或者环境噪音。

录音方式有两种:边录边转写和录完再转写。组件库默认边录边转写,因为等待时长可控,体验接近实时。边录边转写有个坑:音频格式必须和服务商 SDK 匹配,很多 SDK 要求 pcm 或者 wav,组件内部会做格式转换。录音文件存在缓存目录,onResult 回调之后自动清理,不清理的话,长时间使用 App 会积累大量缓存文件,几百 MB 的垃圾就是这么来的。

另外一个要注意的点,模拟器上麦克风基本不可用。调试语音转写尽量用真机,而且真机也要先在系统设置里授权麦克风权限,不然组件内部弹了权限申请也是白弹。我见过不少同事在模拟器上点按钮没反应,第一反应是组件坏了,其实换个真机就好了。真机调试的时候,如果权限弹窗第二次不再出现,记得去系统设置里手动打开权限,这是 Android 权限机制的固有行为,不是组件问题。

3.4 图像分析组件:ImageAnalyzeCard

图像分析组件支持拍照和相册选图两种入口,拿到图片后调用 AI 图像理解能力,返回分析文本展示在卡片上:

ImageAnalyzeCard( prompt = "识别这张图里的文字内容", source = ImageSource.Gallery, onResult = { text -> viewModel.imageText.value = text } )

组件内部集成了图片选择器,相册入口用 PickVisualMedia,拍照入口用 TakePicture,还会做图片压缩。图片压缩非常关键,相机拍出来的一张照片可能 5MB 以上,直接传给 AI 服务商,上传慢还容易超时。组件内部默认压缩到最长边 1080 像素、质量 85%,这个参数可以配。如果识别的是文档类图片,建议把质量提到 90% 以上,文字边缘更清晰,识别率会高不少。

分析结果的展示和文字生成组件类似,也是 Loading、Streaming、Success 这一套状态。项目里如果只需要取结果、不需要 UI 展示,也可以把展示部分隐藏,只保留入口按钮。因为图像分析结果往往很长,建议拿到结果后放到可滚动的区域,或者让用户复制保存。组件默认在结果尾部放了一个复制按钮,这个按钮的显隐也可以用参数控制。

3.5 自定义槽位与样式扩展

组件库虽然提供了默认样式,但实际业务里肯定要定制。所以每个组件对外暴露了 slot 参数,比如 TextGenCard 的 loadingContent、errorContent、resultContent,VoiceTranscribeButton 的 recordingIcon、normalIcon。传自定义 Composable 进去就能完全替换默认 UI。如果你是组件库维护者,slot 参数命名最好和 Compose 官方习惯保持一致,用 content 后缀,开发者用起来几乎不用看文档。

这种 slot 设计借鉴了 Compose 的 content 惯例。实现上就是组件内部判断当前状态,是 Loading 就调用 loadingContent(),是 Streaming 就调用 resultContent(text)。传 null 就显示默认 UI,传了自定义就用自定义。这样做的好处是组件逻辑完全复用,UI 彻底交给业务侧。金融类 App 可以把生成结果包一层自己的水印和样式,社交类 App 可以在结果下面挂自己的话题标签,都不用改组件内部逻辑。

4. 关键原理拆解:组件底层是怎么跑起来的

4.1 怎么把回调式 SDK 扭成 Flow

前面提到的 Provider 层是统一接口抽象,但实际操作中最棘手的是怎么把服务商 SDK 里的 Listener 回调转成 Compose 能用的数据流。大多数 AI SDK 都是 addListener、removeListener 模型,而 Compose 里需要的是 Flow,这个转换就是 callbackFlow 的典型应用场景。

核心代码如下:

fun observeChatStream(prompt: String): Flow<String> = callbackFlow { val listener = object : ChatListener { override fun onToken(token: String) { trySend(token) } override fun onDone() { close() } override fun onError(error: Throwable) { close(error) } } sdk.startChat(prompt, listener) awaitClose { sdk.stopChat(listener) } }

这段代码里 trySend 在协程取消后会直接返回失败,不需要处理;close 在正常结束时调用;awaitClose 挂起直到 Flow 被取消,在协程取消时执行清理逻辑。这个模式下,无论组件重组几次,底层协程取消后 SDK 的监听器都会被注销,不会泄漏。这里注意不要用 trySend 的返回值做判断,因为回调线程和收集线程不是同一个,发送失败并不代表业务逻辑出错。

顺手提醒一下,不要用 channelFlow 代替 callbackFlow。channelFlow 的 channel 是独立的,callbackFlow 和回调模型更贴合,性能也更好。现有回调里如果还有 onStart 提示,可以放到 flow 的 onStart 操作符里发,这样 UI 能区分“连接中”与“生成中”。流式回调里如果偶尔会出现乱序,可以在 Provider 适配层加序号校验,保证 UI 层拿到的 token 顺序正确。

4.2 生命周期与资源回收的硬规矩

AI 请求是长耗时操作,生命周期处理是组件库的重中之重。组件内部所有请求都挂在 rememberCoroutineScope 创建的协程上,并且在 DisposableEffect 的 onDispose 里 cancel,保证这个 Composable 退出组合时,请求一定被取消、监听一定被注销。

@Composable fun TextGenCard(...) { val scope = rememberCoroutineScope() val state = remember { MutableStateFlow(AiUiState.Idle) } DisposableEffect(Unit) { onDispose { scope.cancel() } } }

这里有个细节,rememberCoroutineScope 的作用域跟随当前组合的 lifecycle,但 DisposableEffect 的清理一定要写。即使它本身会取消,显式 cancel 可以更快释放,尤其是请求已经发出、用户退出了页面,如果不显式取消,底层请求还会在后台继续跑,白白消耗用户流量和电量。实测下来,不写 onDispose cancel 的版本,每次退出页面后网络请求还会继续存活几秒到几十秒不等,流量消耗很客观。

回调闭包里最容易踩的坑是持有旧状态。组件里如果传入了回调参数,直接用参数写闭包,会在重组后拿到旧值。解决办法是 rememberUpdatedState,把最新的回调包一层,闭包里永远拿最新版本,不产生额外重组。组件库对外所有回调参数在内部都走了这个包装。这个坑藏得很深,表面上看代码逻辑没问题,但页面刷新的时机一复杂,就会随机出现“回调里拿到的参数是上一次的值”这种诡异问题。

4.3 组件事件怎么回传不丢

事件回传分两种,一种是状态,一种是事件。状态用 StateFlow 收集,组件内部收集后映射到 UI。事件是“一次性”的,比如识别完成提示、请求失败提示,这类如果也用 StateFlow,重组时很可能会重复消费,弹出多个 Toast。组件内部所有状态切换和业务回调用的是两套通道,状态走 StateFlow,事件走 Channel,中间没有任何交叉。

一次性事件我选择在组件内用 Channel 发送,业务侧如果需要监听则通过回调暴露。组件回传事件只有两种途径:参数回调或者 SharedFlow。参数回调适合业务直接把事件接到 ViewModel 里的场景;SharedFlow 适合多个组件共享同一个事件流的场景,用 replay=0 保证新订阅者只能收到之后的事件,不会消费历史事件。如果业务侧想监听全局 AI 事件,可以注入一个 AiEventBus,组件内部把事件 post 进去,适合做数据埋点、统一日志、全局错误上报这类的场景。

靠着这个原则,组件库里的 Toast、Snackbar、日志记录都能做到只触发一次,保持和业务调用方的事件语义一致。实际使用中,组件内部弹出的提示用 Channel 接收后只消费一次,业务侧不需要额外做防抖。这个模型虽然简单,但比起“回调里塞一个 id,页面自己去重”的老办法,干净太多了。

5. 常见问题与排查实录

5.1 组件点不动,Log 里也没有任何输出

组件点不动,大概率不是组件坏了,而是 Provider 没注册。AiKit.registerProvider 漏掉任何一个,对应组件点击后都会直接抛异常,异常如果没被全局捕获,界面看起来就是“什么也没发生”。排查方法很简单:先看 Logcat 里有没有 ProviderNotFound 的 WARNING,再看 Application 里注册代码是否执行到。还有一个容易被忽略的坑:Application 的 onCreate 里如果用了异步初始化,Provider 注册是在子线程执行的,而组件在主页面上同步读取 Provider,就会拿到 null。

注册本身是纯内存操作,放在 onCreate 同步部分即可,没必要异步。我早期在某个项目里把注册放到了线程池里,结果冷启动后快速点击组件必现空指针,后来改成同步注册就好了。另外,如果你用的是多 Module 工程,记得在组件库的初始化模块里统一完成注册,别分散到各个 feature 模块里,否则会出现“我这个页面能用,那个页面不能用”的灵异现象,最后查下来是模块初始化顺序不对。

5.2 流式吐字卡顿、界面掉帧

流式输出卡顿,最常见的原因是把整个页面都变成了观察者。如果 AI 组件把文本 StateFlow 直接暴露给了页面顶层 State,整个页面每个 token 都会重组。解决思路是收窄状态作用域,AI 生成的文本变化只让文本组件自己重组。实现上把组件内部状态封装好,不外传到页面级,页面级只收集稳定的最终状态。这算是 Compose 性能优化里“状态提升”的反向操作:需要共享的状态往上提,不需要共享的状态往下沉。

另一个原因是文本更新用字符串拼接,表面上看是 O(n),但每次拼出新字符串都会触发一次 diff,大文本场景下损耗不小。处理方式是使用增量文本渲染,用 remember 保存已经渲染的文本,新 token 来的时候只 append 变化部分。组件库里对外提供 onTokenUpdate 增量回调,也是为了让业务侧可以做局部更新而不用全量替换。如果你在外面自己拼全文再整体 set 进去,那就等于把流式更新的性能优势全扔了。

5.3 切后台回来,生成结果丢了

AI 请求在切后台再回前台的时候结果丢失,一般是两个原因:一是协程作用域绑定了页面,Activity 销毁时组合退出,请求被取消;二是进程被系统回收,内存里的状态全没了。第一个原因建议把请求状态提升到 ViewModel,组件允许传入外部状态,由 ViewModel 持有,这样配置变更或者重建不会丢;第二个原因要配合 SavedStateHandle,把关键 prompt 和已生成文本存下来,恢复时重新发起请求。

我在一个演示项目里遇到过更隐蔽的问题:切后台后服务商底层连接断开,但组件还显示正在生成,等了好几分钟也没结果。后来在组件里加了超时机制,默认 90 秒没有任何新 token 就自动判失败并给出可重试提示。这个超时参数对网络波动场景很有用,你可以根据实际服务商的最长首字时间调整。关于断网重连,组件内部做了指数退避重试,但只重试两次,再多就要人工介入了,否则用户会以为页面卡死。

5.4 权限、合规与安全细节

语音转写用到的麦克风权限、图像分析用到的相机和相册权限,都属于运行时敏感权限。组件内部虽然会申请,但业务侧要保证 AndroidManifest 里声明的权限存在,否则申请会被系统直接拒绝。另外,App targetSdkVersion 如果比较高,除了运行时权限还有系统限制的变化,组件库在 Manifest 里需要的权限声明和 uses-feature 标签,接入文档里都有列,这部分直接照抄就行。

数据安全方面有三条经验:第一,尽量只上传 AI 任务需要的最小数据,比如语音转写只上传音频流,不要把通讯录、位置信息一起塞进去;第二,录音文件和压缩后的图片用完即删,组件默认存到缓存目录并自动清理,但如果你自己接入了外部存储路径,一定记得自己清理;第三,AI 生成内容要在 UI 上明确标注“由 AI 生成”,这个既是产品体验要求,也是合规要求。尤其涉及资讯、金融、医疗内容时,生成结果的误导性风险要提前评估,组件库在结果区域默认带一个“AI 生成”标识,可以关掉但不建议。

6. 踩坑复盘:我在这套组件库上总结的几条经验

6.1 状态先行,组件后写

整理这套组件库的过程中,我最大的体会就是状态模型要先于组件落地。第一版我图省事,想着每个组件自己维护状态就行,结果做到第三个组件就崩了:A 组件用 Boolean 表示是否正在请求,B 组件用枚举表示阶段,C 组件直接塞字符串判断。后面要做统一错误重试时,每个组件都要单独写适配逻辑。后来全部切到统一的 sealed interface,才把状态逻辑收敛回来。

如果让我重来一次,我会先花半天把 AiUiState 这个模型定死,再开始写第一个组件。因为所有组件都依赖这个模型,一旦后期改字段,所有组件都跟着改,改动量非常大。状态模型的每个字段都要写清楚意义:text 是已生成的完整文本、token 是最近一个增量 token,这两个字段如果混用,后期排查问题会非常痛苦。我见过有人把 token 直接塞进 text 字段,结果 UI 上永远只显示最后一个字。

6.2 用 MockProvider 做真机调试

这是我认为最值得分享的技巧。组件库内置了 MockProvider,不需要真实服务商 Key,模拟了完整状态流转:点击后 Loading 两秒,然后每个 300 毫秒吐出一个 token,最后正常结束。MockProvider 可以控制失败率、流式速度、空结果等各种边界场景,用它在真机上调试 UI,比拿真实服务商 SDK 调试效率高很多。真实 SDK 的调用有配额限制,也依赖网络环境,MockProvider 把这些变量全去掉了,调试 UI 样式和交互时可以专心看界面表现。

真机调试时我发现,AI 组件在 Preview 预览环境里也能跑,因为 MockProvider 不依赖网络和真实 SDK。所以可以直接用 @Preview 写页面 Demo,把 AI 组件放在预览里做视觉调试,不用每次烧到真机看效果。这也是 Compose 声明式 UI 的好处之一。我做组件库的时候,每个组件都配了一个 Preview 函数,接上 MockProvider,团队其他人打开 Android Studio 就能看到组件在不同状态下的表现,评审 UI 的效率高很多。

6.3 后续演进方向

这套组件库后续我计划做三件事:一是把更多 AI 能力组件化,比如语音合成、文生图、代码生成,目前聊天、语音转写、图像分析只是第一批;二是接入本地端侧推理模型,让部分轻量任务不依赖网络,在无网环境也能跑基础功能,比如离线关键词提取、离线意图识别;三是做一个可视化配置面板,业务侧不用写代码就能配置模型参数和组件样式。

最后再说一句个人体会:AI 组件库的价值不在于把 API 包了一层,而在于把 AI 交互里反复出现的状态流转、流式更新、生命周期管理这些难题沉淀成了可靠实现。你可以在自己的项目里封装一套精简版,按本文的状态模型和生命周期思路做,大概率不会跑偏。尤其是那个 sealed interface 的状态模型,哪怕不引入任何组件库,只把它用在你自己现有的 AI 调用代码里,都能让状态管理清晰好几个档次。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询