
Kotlin 这两年有点“闷声发大财”的意思。语法层面大家早就知道它是 Android 官方语言但最近围绕它的讨论明显换了一批关键词adk kotlin 的 model 内置 gemini、在 JVM 上跑通一个 agent、BluetoothGattCallback 改成 suspend、回调转挂起工具类、SVG 转 Compose ImageVector。每一个都不是单纯的新语法点而是实打实的工程落地场景说明 Kotlin 正在从“Android 的 Kotlin”变成“Kotlin 的 Android”语言本身开始主导工具链、架构选型和 AI 应用开发。这次我不想再讲一遍基础语法而是围绕最近几个高频实战场景把我在 JVM 上跑 agent、封装蓝牙回调、处理 SVG 转 ImageVector 时踩过的坑和验证过的写法整理出来。无论你是 Android 开发者、后端想切入 AI 工程化的人还是刚学完 Kotlin 语法不知道下一步干什么的初学者下面的内容应该能帮你快速找到手感。1. Kotlin 正在出圈从 Android 标配到多端基础设施1.1 ADK 选择 Kotlin 的逻辑协程就是天然的 Agent 编排器Agent 开发的本质是异步任务编排接收用户输入判断该调用哪个工具等待工具返回结果再决定下一步动作。这个过程在 Python 里通常要依赖额外的异步框架或链式抽象但在 Kotlin 里suspend 函数本身就是最直观的编排原语——你不需要太多外部工具用一个挂起函数把步骤串起来中间遇到 IO 等待时协程会自动让出线程代码读起来和同步逻辑几乎一样。Google 开源的 Agent Development Kit简称 ADK之所以把 Kotlin 列为官方支持语言很大程度上就是看中了协程这套能力。ADK 的 Kotlin 版本目前内置了 Gemini 模型接入你只需要通过环境变量或配置文件提供密钥就能直接构造一个可运行的 model 对象。其他模型也可以通过自定义模型接口接进来但默认开箱即用的体验确实是“配置一把梭”。另一个细节是类型安全。在 Kotlin 中定义 Agent 工具时参数可以用 data class 描述函数签名编译器会帮你校验而在纯字符串协议的工具调用场景里参数写错往往要等运行时才会暴露。这一点在人机交互频繁、工具数量多的时候能减少非常多低级错误。1.2 一份 Kotlin 逻辑JVM 与 Android 通吃热词里提到的“adk.dev 的 kotlin 快速上手在 JVM 上跑通一个 agent”本质上是 Kotlin 跨平台能力的缩影。同样的 agent 编排逻辑在 JVM 上可以用 kotlinx-coroutines 处理并发在 Android 上可以用 Retrofit/OkHttp 做网络层语言层不变变的只是依赖。对于后端团队来说这也是一个很现实的优势从 Java 迁移到 Kotlin 几乎是零成本的现有 Java 库可以直接调用迁移过程可以逐文件渐进推进。我自己的体感是Kotlin 的数据类、密封类、DSL 能力在描述 agent 状态和指令时特别好用。比如 agent 的消息类型可以用 sealed interface 定义成 UserMessage、ToolCall、ToolResult编译器强制穷尽 when 分支少了很多“忘了处理某类消息”的隐患。这些能力叠加协程之后Kotlin 在服务端 AI 工程里的存在感只会越来越强。1.3 Compose 工具链反过来改变了资源处理流程SVG 转 ImageVector 这个热词看着像是小工具其实是 Compose 生态推动下的标准动作。Compose 默认把矢量图作为一等公民同一个图标资源既能自适应缩放又能通过 tint 随心换色这比过去准备一套 xxhdpi、xxxhdpi PNG 再让设计师重新出图要省事太多。于是设计师导出 SVG、开发者转成 ImageVector就成了一条新的协作链路。所以你会发现现在的 Kotlin 学习已经不是“背语法”那么简单了。它牵扯到 AI agent、协程封装、Compose 工具链每一个方向都能独立成章。接下来我按实操顺序分别讲这三个场景的具体落地方法。2. 在 JVM 上跑通第一个 Kotlin AgentADK 快速上手实操2.1 项目初始化与依赖引入先创建一个纯 Kotlin JVM 项目。我用的是 Gradle Kotlin DSLJDK 版本必须 17 以上否则 ADK 的字节码和 Kotlin 元编程会报一些莫名其妙的错误。build.gradle.kts 大致如下plugins { kotlin(jvm) version 2.0.20 } repositories { mavenCentral() google() } dependencies { implementation(com.google.adk:adk-kotlin:0.1.0) // 具体版本以官方最新为准 implementation(org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1) implementation(org.slf4j:slf4j-simple:2.0.13) } kotlin { jvmToolchain(17) }一个是 kotlinx-coroutines一个是 slf4j这两个是跑通 demo 的关键。没有 slf4j 实现时很多 ADK 日志会被静默吞掉出了问题你根本不知道是模型没返回还是 agent 没被调用。kotlinx-coroutines 的版本要特别注意和 Kotlin 编译器版本尽量匹配我遇到过 1.9.x 和旧版 Kotlin 混用导致编译器直接崩溃的情况。2.2 最小可运行 Agent 代码依赖配好后写一个最简单的 main 函数就能把 agent 跑起来import com.google.adk.agents.Agent import com.google.adk.models.GeminiModel import com.google.adk.sessions.InMemorySession import kotlinx.coroutines.runBlocking fun main() runBlocking { val model GeminiModel( modelName gemini-2.0-flash, apiKey System.getenv(GEMINI_API_KEY) ) val agent Agent( name travel_assistant, model model, instructions 你是一个旅行助手回答要简洁实用直接给出行程建议。 ) val session InMemorySession() val response agent.run(推荐一个三天两夜的杭州行程, session) println(response.text) }这里的 GeminiModel 负责模型接入Agent 负责配置 agent 的名称、模型和提示词InMemorySession 负责保存多轮会话上下文。如果你会话隔离做得好第二次调用 agent.run 时把同一个 session 传进去模型就能记住之前的对话如果每次新建 sessionagent 等于失忆。代码里四个核心概念值得说一下model 是接入层agent 是逻辑实体session 是记忆载体run 是入口。这个拆分并不复杂但它是后续所有 agent 工程化的基础我看过不少人直接把提示词拼在字符串里然后循环调接口那就没吃到 ADK 的红利。2.3 给 Agent 注册一个工具单靠对话能力agent 的价值有限真正实用的是让它能调用外部工具。ADK 里工具函数用注解标注即可import com.google.adk.functions.AgentFunction AgentFunction( name get_weather, description 查询指定城市的当前天气 ) suspend fun getWeather(city: String): String { // 这里替换成真实天气 API 调用 return 杭州多云24°C东南风2级 } fun main() runBlocking { val agent Agent( name travel_assistant, model model, instructions 回答要简洁实用需要天气信息时调用 get_weather。, tools listOf(::getWeather) ) // ... }注意工具函数是 suspend 的这意味着它内部可以自由调用其他挂起函数做网络请求不会阻塞线程。ADK 运行时收到模型返回的 tool call 后会从工具列表里找到匹配函数执行再把结果回填给模型让它基于真实数据继续回答。我建议所有工具函数都写成 suspend哪怕当前实现只是查内存里的 Map。理由很简单真实业务里工具迟早要访问数据库或第三方接口提前用 suspend 签名可以让调用侧永远不需要为“以后要异步”做重构。2.4 运行效果与常见排查第一次运行这个 demo 时你可能会遇到几个问题我把排查经验整理成表格现象常见原因处理方式程序启动后没有输出SLF4J 没有绑定实现添加 slf4j-simple 依赖找不到 GeminiModel 类ADK 版本不同导致包路径变化去 adk.dev 查当前版本的导入路径模型返回空文本instructions 太模糊或 session 没传给 agent 写明确指令确认同一个 session 复用API key 读取为空IDE 运行配置里没配环境变量在 Run Configuration 中配置 GEMINI_API_KEY协程版本冲突kotlinx-coroutines 与 Kotlin 编译器不匹配降级到与 Kotlin 2.0.x 匹配的 1.8.xGradle 构建报 JDK 相关错误本地 JDK 版本低于 17升级 JDK 并同步 Gradle JVM 配置还有一点容易被忽略demo 里用 runBlocking 只是图方便真要在服务端接 HTTP 请求要避免在 Web 框架的线程里直接 runBlocking应该用协程作用域包装。另一个安全细节是不要把密钥硬编码进代码也不要把用户提供的敏感信息直接拼进提示词云端模型调用要遵守服务条款和数据合规要求。3. 回调地狱终结者Kotlin 协程改造 BluetoothGattCallback 与通用回调转挂起工具3.1 传统 BluetoothGatt 代码为什么难写Android 蓝牙操作几乎是“回调地狱”的教科书案例。连接设备要等 onConnectionStateChange连接成功后再 discoverServices又要等 onServicesDiscovered读到特征之后还要等 onCharacteristicRead。每一步都是异步回调而且回调线程还不一定相同所以你经常能看到一段代码里嵌套四五层回调中间穿插各种 if (status GATT_SUCCESS) 判断。更麻烦的是BluetoothGattCallback 不是一次性事件监听器。同一个回调方法会在不同时机被触发多次比如连接状态变化可能从 CONNECTING 到 CONNECTED也可能直接跳到 DISCONNECTED。因此你想把一个“连接完成”包装成 suspend 函数必须考虑回调重复进入、超时、协程取消后连接未关闭等多个边界条件。这也是很多人尝试自己封装时容易写出崩溃代码的原因continuation.resume 被调用两次会直接抛 IllegalStateException。3.2 suspendCancellableCoroutine 封装单个回调我自己最常用的方案是 suspendCancellableCoroutine而不是 suspendCoroutine。区别只在一行但非常重要Cancellable 版本支持协程取消回调你可以把断开蓝牙连接的动作放到 invokeOnCancellation 里确保协程被取消时底层连接不会一直挂着。一个连接等待的封装大概长这样suspend fun BluetoothGatt.awaitConnection(timeoutMillis: Long 10000): Boolean withTimeout(timeoutMillis) { suspendCancellableCoroutine { continuation - val callback object : BluetoothGattCallback() { override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) { if (!continuation.isActive) return val success newState BluetoothProfile.STATE_CONNECTED status BluetoothGatt.GATT_SUCCESS if (success || newState BluetoothProfile.STATE_DISCONNECTED) { continuation.resume(success) } } } continuation.invokeOnCancellation { runCatching { disconnect() } } connectGatt(context, false, callback) } }这里面有几个关键判断。一是用 continuation.isActive 防止协程已被取消后回调仍然尝试 resume二是把连接成功和断连都当成“终点事件”三是通过 withTimeout 避免设备无响应时永久挂起。实际上status 不等于 GATT_SUCCESS 时也应该走失败分支这个细节在系统回调里非常容易漏。3.3 通用回调转挂起工具类的设计思路蓝牙只是典型场景回调转挂起的通用模式其实可以抽象成一个工具类。核心思路是用一个容器保存当前挂起的 continuation外部回调进来后调用 complete 或 fail 结束这次挂起。我简化过的骨架如下class CallbackAwaiterT { private val continuationRef AtomicReferenceCancellableContinuationT?(null) suspend fun await(dispatcher: CoroutineDispatcher Dispatchers.Main): T withContext(dispatcher) { suspendCancellableCoroutine { continuation - continuationRef.set(continuation) continuation.invokeOnCancellation { continuationRef.compareAndSet(continuation, null) } } } fun complete(value: T) { continuationRef.getAndSet(null)?.resume(value) } fun fail(e: Throwable) { continuationRef.getAndSet(null)?.resumeWithException(e) } }用的时候在回调里调用 complete在业务代码里调用 await就能把一个经典回调改写成顺序调用的挂起函数。AtomicReference 保证 complete 和 fail 只会作用到当前的 continuation同时避免并发环境下丢失状态。不过这个工具类只是骨架生产环境要补充几种能力超时控制、弱引用防止内存泄漏、线程切换策略。回调经常发生在系统 binder 线程而 UI 更新通常要求主线程所以 await 阶段最好通过 dispatcher 参数显式指定恢复线程。蓝牙这种需要串行操作的设备我还会在外面套一把 Mutex避免同时发多个读写请求导致协议栈混乱。3.4 改造后的实测收益与避坑清单把一套完整 BLE 读写逻辑从回调嵌套改成挂起函数之后代码结构会变得特别直观withContext(Dispatchers.IO) { val connected gatt.awaitConnection(8000) check(connected) { 连接超时 } val characteristic gatt.awaitDiscoverServices().let { it.getCharacteristic(uuid) } val result gatt.awaitRead(characteristic) }对比之前四五层回调嵌套这段代码的阅读成本低了一个量级。测试的时候也能明显感觉到错误分支更容易写了连接失败直接抛异常外面 catch 一下就好不用在层层回调里传错误码。当然坑也不少。ConnectionStateChange 在不同 Android 版本里参数有差异API 33 之后回调方法签名多了 bondState 参数封装层必须做版本适配否则编译都过不了。另一个高发问题是 continuation 被 resume 两次蓝牙协议栈在网络抖动时确实会触发重复状态回调防重入逻辑不能省。最后是协程取消后的资源释放invokeOnCancellation 里一定要断开连接并清理回调引用不然下次扫描会发现设备处于半连接状态。4. 从 SVG 到 ImageVectorCompose 图标处理新流程4.1 为什么要把 SVG 搬进 ComposeCompose 的 ImageVector 是按需绘制的矢量图形不是位图。它的好处有三点任意缩放不糊、可以通过 tint 一键换主题色、资源体积比多套 density 的 PNG 小很多。传统 Android 项目里一个图标往往要放 drawable-xxhdpi、drawable-xhdpi 等多个目录设计师换个颜色还得重新导出。而用 ImageVector 之后一个资源文件加一行 tint 就能解决所有换色需求。但 Compose 不能直接识别 .svg 文件所以需要把 SVG 转成可用的 ImageVector 资源。这里有两类做法一类是 Android Studio 内置的 Vector Asset 转换适合单张处理另一类是批量转换工具或 Gradle 插件适合图标库级别的迁移。先从 IDE 内置的用起。4.2 IDE 内置转换的操作路径Android Studio 里导入 SVG 的路径并不复杂在 res 目录上右键选择 New然后选 Vector Asset。导入面板里选 Local file指向你的 .svg 文件Studio 会解析后生成一份 XML 格式的 VectorDrawable。之后在 Compose 代码里可以直接用 vectorResource 把它转成 ImageVector 使用import androidx.compose.ui.res.vectorResource val icon: ImageVector vectorResource(R.drawable.ic_settings)如果你只是想在布局里显示也可以直接用 painterResource(R.drawable.ic_settings)它内部同样会加载为可绘制的矢量对象。要注意的是生成的 xml 本质是 Android 的 VectorDrawable 格式Compose 的 vectorResource 能读取它但它并不等于“一步到位生成 Kotlin 的 ImageVector 源码对象”。想要纯代码的 ImageVector 对象通常需要借助其他转换器但我个人认为日常项目用 vectorResource 就足够了没必要为了“纯 Kotlin”而强上代码生成。4.3 复杂 SVG 的预处理方法Android Studio 的内置转换器并不是万能的它只支持 SVG 的一个子集。像 path、rect、circle、polygon、基本 fill 这些可以顺利转换但渐变、滤镜、mask、文本、复杂描边这些特性转换结果往往缺胳膊少腿甚至直接报解析失败。我的处理流程是先做简化再转换而不是拿原图硬怼。具体分三步先用 SVG 编辑器或浏览器开发者工具检查 SVG 里的元素把 text、filter、mask、linearGradient 这类装饰性元素删掉只保留 path然后统一 viewBox 尺寸让设计资源本身清晰且路径数量可控最后再用 IDE 导入并进行视觉对比。如果发现某个 path 填充的是渐变我会手动把 fill 改成实际色值因为转换器处理不了 url(...) 形式的填充。关于复杂度的另一个教训是路径数量。ImageVector 底层绘制在 Android 的 Canvas 上有指令数上限大约 64K 条 path 指令。如果设计稿里一个图标有上千条 path强行转换轻则生成缓慢重则运行时崩溃。这时候正确的做法是回到设计源头简化图形而不是在开发侧硬扛。4.4 SVG 特性支持速查表我根据实测整理了一份支持情况对照表方便你判断手上的资源能不能直接转SVG 特性转换支持情况path 基本 fill支持rect / circle / polygon支持多个 path 同色填充可能合并需要检查线性渐变 fill部分场景可转 gradle复杂的不行opacity可能丢失需要手动确认stroke / dasharray多数忽略建议先转 pathfilter / mask / clipPath不支持直接删除或简化text 元素不支持转成 path 后再处理这里特别提醒一个 Compose 实战细节ImageVector 是密度无关的所以它不会像 PNG 一样出现小尺寸发糊的问题但 Compose 预览有时候会显示空白。遇到预览空白先看一眼生成的 vector xml 是否完整再看看 build 产物有没有被正确打进资源优先排查这两个点。5. 踩过这些坑之后我对 Kotlin 实战学习的几点体会5.1 最值得花时间掌握的是协程的“取消”语义很多人学 Kotlin 协程只学 async/await觉得能省掉回调就够了但真正到项目里取消才是最难的部分。协程取消后底层资源释放了吗系统回调还会不会继续进来continuation 会不会被重复 resume这些问题不是语法层面能直接看到的必须动手封装一次蓝牙回调或者写一个自定义挂起工具才能真的懂。我给团队做分享时经常说谁能在不看文档的情况下把 suspendCancellableCoroutine、invokeOnCancellation、withTimeout 三件套组合起来写一个带超时和资源清理的回调转挂起工具谁就算是真正入了 Kotlin 的门。5.2 用三个小项目检验自己的水平如果你刚学完 Kotlin 基础不知道该做什么我建议直接把前面三个场景各做一遍跑通一个 ADK 的 JVM agent体验协程在 AI 编排里的地位封装 BluetoothGattCallback理解回调转挂起的边界条件把一个 SVG 图标转成 ImageVector 并在 Compose 里动态换色走一遍现代资源处理流程。这三个任务分别覆盖了协程、类型系统、Compose 工具链而且每个都不需要庞大的业务背景两到三周基本能全部打通。5.3 给团队引入 Kotlin 的一点小建议Kotlin 进团队是渐进式的我不建议搞断崖式重构。比较好的节奏是第一个月只在新文件里用 Kotlin并且把 Java 和 Kotlin 的互调链路跑通第二个月把工具类、常量类、数据类这类无状态或低状态模块迁过去第三个月再动网络层和业务层。这样风险小大家也有时间熟悉语言特性。我自己在项目里就是这么推进的整个过程没有出现过一次因为混编导致的编译阻塞。最后再分享一个小技巧写回调转挂起工具时先用一个假的回调源比如 Thread.sleep 手动回调做单测把超时、重复回调、取消这三种情况覆盖掉再接入真实硬件或系统 API。这个习惯帮我省下过不少连真机调试的时间。