资讯详情

资讯详情

Android登录模块改造Agent Harness开发环境实战

1. 为什么我拿登录模块来搭 Agent 开发环境先说清楚这套东西到底在干什么。Harness 这个词在 Agent 开发圈子里指的是包裹在模型外面那一层负责调度、约束、观测的工程骨架——模型本身只会吐 token真正让它变成一个能干活、不出格、可复现的智能体靠的全是这层骨架。而 Android 登录模块是我试过最适合拿来练手 Harness 工程的场景它有明确的状态机、有真实的异步 IO、有失败重试、有凭据管理、有 UI 反馈几乎把 Agent 开发里所有容易翻车的地方都浓缩进了一个不到两千行的模块里。你可能会问搭 Agent 环境为什么不去搞个 Python 脚本或者 Node 服务非要折腾 Android。我的理由很直接Android 的 Kotlin 协程模型和 Jetpack Compose 的声明式 UI天然就是一套“状态驱动 副作用隔离”的架构这跟 Agent 的 Harness 层要解决的问题高度同构。Agent 执行过程中最怕的就是状态漂移和副作用失控而 Compose 的State加协程的Flow恰好给了你一套强制你把这些东西想清楚的工具。用登录模块当靶子你能在一个真实可运行的 App 里把 Harness 的每一个环节都摸一遍。这套环境适合谁如果你已经会写一点 Kotlin能看懂suspend函数但对“Agent 到底怎么被管起来”这件事只有模糊概念那这篇就是给你写的。如果你是完全的新手也没关系我会把每个关键点拆到能直接抄的程度。整篇内容围绕一个核心目标让你在 Android Studio 里跑起来一个登录模块并且把这个模块的骨架改造成一个可复用的 Agent 执行环境。我踩过的坑先摆一个出来一开始我以为 Harness 就是写个try-catch把模型调用包起来结果发现真正难的是超时后的状态回收和并发请求下的凭据刷新。登录模块里“token 过期后自动刷新”这个逻辑跟 Agent 里“工具调用失败后重新规划”几乎是同一类问题。所以别小看这个场景它比你想的深。2. 环境搭建与项目骨架设计2.1 Android Studio 与 Kotlin 工具链的选型理由工欲善其事第一步是把 Android Studio 装对。我目前稳定用的是 Android Studio 的较新稳定版配套 JDK 17。为什么不追最新预览版因为 Harness 工程本身要处理大量协程和编译期注解预览版的 Kotlin 编译器偶尔会在suspend相关的类型推断上抽风排查起来非常浪费时间。稳定版加 JDK 17 这个组合我连续跑了三个月没出过编译层面的幺蛾子。Kotlin 版本我锁在 1.9.x 系列。这里有个细节compileOnly filetree(dir: libs, include: [*.aar])这种写法在 Gradle 里很常见用来引入那些只在编译期需要、运行期由宿主提供的 AAR。如果你在做 Agent 的插件化 Harness这个配置会经常用到——把 Agent 的能力接口编译进主工程但具体实现留给运行时注入。注意compileOnly不会把 AAR 打进最终产物所以别指望它能在运行时被反射加载到这是两码事。Gradle 的依赖配置我建议这样组织dependencies { implementation(org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3) implementation(androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0) implementation(androidx.compose.material3:material3:1.2.0) compileOnly(fileTree(mapOf(dir to libs, include to listOf(*.aar)))) }compileOnly那一行是给 Agent 能力包预留的。假设你有一个agent-core.aar里面定义了工具调用的接口主工程编译时需要它来通过类型检查但运行时这个 AAR 由外部加载。这种设计在 Harness 工程里很关键因为它让主工程和 Agent 实现解耦你可以换不同的 Agent 后端而不用重新编译整个 App。2.2 登录模块作为 Harness 载体的结构拆解登录模块我拆成四层这个分层直接对应 Harness 的四个职责UI 层Jetpack Compose 写的登录表单负责展示状态和收集输入。对应 Harness 里的“观测与反馈”。ViewModel 层持有登录状态机暴露StateFlow。对应 Harness 里的“状态管理”。Repository 层封装网络请求和凭据存储。对应 Harness 里的“工具调用与副作用隔离”。Harness 层这是我额外加的一层夹在 ViewModel 和 Repository 之间负责超时、重试、并发控制、日志埋点。为什么要单独抽 Harness 层因为如果你把重试逻辑写在 ViewModel 里UI 状态和调度逻辑就搅在一起了测试起来极其痛苦。抽出来之后Harness 层可以独立做单元测试ViewModel 只管把 Harness 吐出来的状态映射成 UI 状态。这个边界划清楚后面加 Agent 能力的时候才不会乱。2.3 用 Compose 构建可观测的登录界面Compose 的声明式写法在这里帮了大忙。登录界面我只需要描述“状态是什么样界面就长什么样”不用手动去findViewById再setText。核心状态用一个密封类表示sealed interface LoginState { data object Idle : LoginState data object Loading : LoginState data class Success(val token: String) : LoginState data class Error(val message: String, val retryable: Boolean) : LoginState }retryable这个字段是 Harness 层塞进来的。网络超时是可重试的密码错误是不可重试的这个区分直接决定了 UI 上要不要显示“重试”按钮。你看Harness 的决策就这样自然地流到了 UI 层而不是让 UI 去猜。进度条我用的是LinearProgressIndicator绑定Loading状态。这里有个小技巧进度条不要用if (loading) show()这种命令式写法而是让状态驱动。因为 Agent 执行过程中状态切换非常频繁命令式写法很容易出现“状态已经变了但 UI 没跟上”的鬼影。3. Harness 核心机制把登录流程改造成 Agent 执行骨架3.1 状态机设计从 Idle 到 Success 的完整流转登录的状态流转看起来简单但加上 Harness 的重试和超时之后分支会变多。我画过一版状态转移表贴在下面当前状态事件下一状态说明Idle用户点击登录Loading校验输入后进入Loading请求成功Success保存 tokenLoading请求超时Error(retryabletrue)允许重试Loading凭据错误Error(retryablefalse)不允许重试Loading用户取消Idle协程取消Error(retryable)用户点击重试Loading重新发起这张表的价值在于它逼你把每个分支都想清楚。Agent 执行也是一样agent execution terminated due to error这种报错十有八九是因为状态机里漏了某个分支导致状态卡死。我在 Harness 层用一个MutableStateFlowLoginState来承载这个状态机所有状态变更都通过一个transition函数走禁止直接赋值。这样任何非法转移都会在编译期或运行期被拦住。3.2 超时与重试Harness 层最容易被低估的部分超时和重试是 Harness 的灵魂。我见过太多 Agent 项目模型调用一超时就整个崩掉或者无脑重试把配额烧光。登录模块里我用withTimeout加指数退避suspend fun T withRetry( maxAttempts: Int 3, initialDelayMs: Long 500, factor: Double 2.0, block: suspend () - T ): T { var delayMs initialDelayMs repeat(maxAttempts - 1) { attempt - try { return withTimeout(10_000) { block() } } catch (e: TimeoutCancellationException) { delay(delayMs) delayMs (delayMs * factor).toLong() } } return withTimeout(10_000) { block() } }这段代码有几个点值得说。第一withTimeout抛的是TimeoutCancellationException它是CancellationException的子类所以如果你在外层用try-catch (e: Exception)会把协程取消也吞掉这是大忌。第二指数退避的factor我设成 2.0初始 500ms三次重试的总等待是 500 1000 1500ms加上三次请求本身的时间用户体验上还能接受。如果设成 3.0等待时间会飙到 500 1500 2000ms再往上就明显卡顿了。注意重试次数不是越多越好。Agent 场景下如果工具调用本身有副作用比如写文件、发请求盲目重试可能造成重复执行。登录这种幂等操作可以放心重试但涉及副作用的操作重试前一定要做幂等性检查。3.3 凭据管理token 刷新与并发安全token 过期刷新是登录模块里最像 Agent 的地方。Agent 执行长任务时中间步骤的凭据也可能过期这时候如果多个并发任务同时发现过期就会触发多次刷新造成竞态。我用一个Mutex加双检锁解决private val refreshMutex Mutex() suspend fun getValidToken(): String { tokenCache?.let { if (!it.isExpired()) return it.value } return refreshMutex.withLock { tokenCache?.let { if (!it.isExpired()) return it.value } val newToken api.refreshToken() tokenCache newToken newToken.value } }withLock内部再检查一次是因为可能在等锁的过程中别的协程已经刷新好了。这个模式在 Agent 的 Harness 里会反复出现——任何共享资源的懒加载都要考虑并发。我实测下来不加这层双检在快速切换账号的场景下会出现 token 被刷新两次、旧 token 覆盖新 token 的问题。3.4 日志与可观测性让 Agent 的每一步都留痕Harness 工程如果没有日志出问题就是两眼一抹黑。我在 Harness 层埋了一个轻量的日志器记录每次状态转移、每次请求的耗时、每次重试的原因。日志不直接println而是走一个Flow收集起来方便后面接 UI 或者上报。private val _events MutableSharedFlowHarnessEvent(extraBufferCapacity 64) val events: SharedFlowHarnessEvent _events.asSharedFlow() fun log(event: HarnessEvent) { _events.tryEmit(event) }用tryEmit而不是emit是因为日志不应该阻塞主流程。extraBufferCapacity设 64 是防止高频事件把缓冲区撑爆。这个设计在 Agent 场景下特别有用因为 Agent 执行会产生大量中间事件你不可能每个都同步处理。4. 实操全流程从零跑通一个可复用的 Agent 环境4.1 项目初始化与依赖配置的完整步骤第一步在 Android Studio 里新建一个 Empty Compose Activity 项目。包名随便起我习惯用com.example.harnesslab。建好之后打开build.gradle.kts模块级把依赖补全。除了前面提到的协程和 Compose 依赖还要加上lifecycle-runtime-compose用来在 Compose 里收集StateFlow。第二步在libs目录下放你的 Agent 能力 AAR。如果没有现成的可以先建一个空的 AAR 占位或者干脆先注释掉compileOnly那行等后面需要了再加。我建议一开始先不加把登录流程跑通再引入 AAR这样出问题容易定位。第三步配置AndroidManifest.xml的网络权限。登录要发请求INTERNET权限是必须的。如果你用的是明文 HTTP 调试还要加usesCleartextTraffic但生产环境千万别这么干。第四步建包结构。我按ui、viewmodel、repository、harness、model五个包来分。这个结构不是死的但 Harness 单独成包很重要因为它将来要复用到别的模块。4.2 登录请求的 Harness 封装与参数计算登录请求本身很简单POST 一个用户名密码拿回 token。但 Harness 封装要考虑的参数不少。超时时间我设 10 秒这个值怎么来的移动网络下DNS 解析加 TLS 握手加服务端处理正常在 2 秒内完成10 秒是留了 5 倍余量。如果超过 10 秒基本可以判定网络异常重试比干等更划算。重试次数设 3 次前面算过总等待 1.5 秒。这个值是在“用户能忍受”和“给网络足够恢复时间”之间取的平衡。我做过小范围测试超过 3 次重试用户放弃率明显上升。并发控制方面登录按钮在Loading状态下要禁用防止用户连点触发多个请求。这个在 Compose 里用enabled state !is Loading就能搞定。但更稳妥的做法是在 Harness 层用一个AtomicBoolean做闸门因为 UI 层的禁用有可能被绕过比如通过无障碍服务触发。private val inFlight AtomicBoolean(false) suspend fun login(username: String, password: String): LoginState { if (!inFlight.compareAndSet(false, true)) { return LoginState.Error(请求进行中, retryable false) } return try { // 实际请求逻辑 } finally { inFlight.set(false) } }compareAndSet是原子操作比if (!flag) { flag true }这种写法安全得多。后者在并发下会有窗口期两个协程可能同时通过检查。4.3 把登录状态接到 Compose UI 的实操记录ViewModel 里持有 Harness 实例暴露StateFlowLoginState。Compose 侧用collectAsStateWithLifecycle()收集。这里有个细节一定要用WithLifecycle版本它会在界面不可见时自动停止收集避免后台无谓的状态更新。Composable fun LoginScreen(viewModel: LoginViewModel) { val state by viewModel.state.collectAsStateWithLifecycle() Column(modifier Modifier.padding(16.dp)) { when (val s state) { is LoginState.Loading - LinearProgressIndicator(modifier Modifier.fillMaxWidth()) is LoginState.Error - Text(s.message, color MaterialTheme.colorScheme.error) else - Unit } Button( onClick { viewModel.login(username, password) }, enabled state !is LoginState.Loading ) { Text(登录) } } }实测下来这套写法在旋转屏幕、切后台再回来这些场景下状态都不会丢因为状态在 ViewModel 里而 ViewModel 的生命周期比 Composable 长。这也是为什么我坚持把状态机放在 ViewModel 而不是 Composable 内部。4.4 用 Harness 事件流做调试面板调试 Agent 的时候光看最终状态是不够的你需要看到中间发生了什么。我在 App 里加了一个隐藏的调试面板长按标题栏触发展示 Harness 事件流。这个面板在开发阶段帮了大忙能看到每次重试的间隔、每次请求的耗时。事件流用LazyColumn渲染每条事件显示时间戳和内容。因为事件可能很多我给SharedFlow设了缓冲区并且调试面板只保留最近 100 条。这个面板的代码不复杂但价值极高建议你在自己的项目里也加一个。5. 踩坑记录与常见问题排查5.1 协程取消被吞导致的诡异卡死最常见的坑就是CancellationException被当成普通异常处理了。比如你写了catch (e: Exception)协程取消时抛出的CancellationException会被捕获然后你的代码继续往下走但协程其实已经被取消了后续的suspend调用会立刻抛出取消异常造成状态错乱。排查方法在 Harness 层的所有catch块里第一件事就是检查if (e is CancellationException) throw e。或者更彻底一点只捕获你明确知道怎么处理的异常类型别用宽泛的Exception。5.2 token 刷新引发的并发问题速查现象可能原因排查方法解决刷新后仍报 401旧 token 覆盖新 token打日志看刷新次数加 Mutex 双检刷新请求风暴多个协程同时发现过期统计单位时间刷新次数加单飞机制刷新死循环刷新接口本身返回 401看刷新接口的响应刷新失败直接登出这张表是我实际遇到问题后整理的。特别是“刷新死循环”如果刷新 token 的接口也需要鉴权而鉴权又依赖 token就会绕不出来。解决办法是刷新接口用独立的凭据或者刷新失败直接跳登录页。5.3 Compose 重组导致的重复请求Compose 的重组很频繁如果你在 Composable 里直接调viewModel.login()每次重组都会触发一次。正确做法是把请求放在LaunchedEffect里或者通过按钮点击触发。我见过有人在LaunchedEffect(Unit)里发请求结果因为 key 设错每次重组都重新执行。记住LaunchedEffect的 key 决定了它什么时候重启Unit表示只执行一次但前提是这个 Composable 不被销毁重建。5.4 网络超时与重试的边界情况超时重试有个边界情况请求其实已经到了服务端并且执行成功但响应在返回路上超时了。这时候重试会导致服务端执行两次。登录这种幂等操作无所谓但如果是“创建订单”这种非幂等操作就会出大问题。Harness 层要支持给每个操作打上“是否幂等”的标记非幂等操作要么不重试要么带幂等键。6. 从登录模块到通用 Agent Harness 的扩展思路6.1 把工具调用抽象成可插拔的能力登录模块里的“发请求”本质上就是一次工具调用。我把这个抽象出来定义成Tool接口interface ToolI, O { val name: String val idempotent: Boolean suspend fun execute(input: I): O }登录工具实现这个接口将来 Agent 要调用别的能力也实现这个接口。Harness 层只认Tool不认具体实现。这样你换一个 Agent 后端只要它提供的工具实现了这个接口Harness 层完全不用改。6.2 用 Skill 组合多个工具完成复杂任务单个工具能力有限Agent 真正干活靠的是把多个工具串起来。我引入Skill的概念一个 Skill 是一组工具的编排。比如“登录并拉取用户信息”就是一个 Skill它先调登录工具再用拿到的 token 调用户信息工具。Skill 内部可以用协程的async做并行也可以用awaitAll等所有结果。这里要注意错误传播。Skill 里某一步失败后面的步骤要不要继续我的做法是默认快速失败但允许 Skill 声明“容错步骤”容错步骤失败只记日志不中断。这个策略在 Agent 场景下很实用因为有些工具调用失败不影响整体任务完成。6.3 观测数据回流让 Harness 越用越聪明Harness 收集的事件流不只是用来调试还可以回流做优化。比如统计每个工具的平均耗时如果某个工具经常超时就可以调整它的超时阈值。或者统计重试成功率如果某个操作重试三次成功率还是很低说明重试策略需要改。我把这些统计做成一个简单的MetricsCollector定期把聚合数据打日志。将来如果接入更复杂的 Agent 调度这些数据就是决策依据。比如“这个工具最近失败率高先别用它换个备选”这就是 Harness 智能化的雏形。6.4 移植到其他平台的注意事项这套 Harness 骨架不绑定 Android。核心的状态机、重试、并发控制都是纯 Kotlin 逻辑可以抽成 Kotlin Multiplatform 模块在 iOS 或者后端复用。唯一需要平台适配的是日志输出和 UI 反馈。我试过把 Harness 层抽出来放到一个纯 Kotlin 模块里Android 侧只保留 UI 和平台相关的网络实现编译通过运行正常。移植的时候注意协程调度器的差异。Android 主线程调度器是Dispatchers.Main后端可能是Dispatchers.Default。Harness 层不要硬编码调度器而是通过构造函数注入这样换平台时只改注入的地方。7. 我个人在实际操作中的几点体会这套东西我前前后后搭了大概两周中间推翻重来过一次。最大的体会是Harness 的价值不在于它多复杂而在于它把“意外情况”都变成了“预期情况”。登录模块里每一个可能出错的地方我都提前在 Harness 层处理了所以上层 UI 写起来特别省心只需要关心正常流程。另一个体会是关于测试。Harness 层因为不依赖 Android 框架可以用纯 JUnit 测试。我写了十几个测试用例覆盖超时、重试、并发刷新这些场景跑起来只要几秒。如果没有这层抽象这些逻辑混在 ViewModel 里测试就得用 Robolectric慢不说还容易 flaky。最后分享一个小技巧Harness 层的事件流我在开发阶段会同时输出到 Logcat 和一个内存环形缓冲区。Logcat 方便实时看环形缓冲区方便出问题后回溯。环形缓冲区的大小设成 256 条足够覆盖一次完整的登录流程。这个双通道日志的设计让我排查问题的效率至少提高了一倍。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →