☰
Android登录模块改造Agent Harness开发环境实战
2026/11/4 15:01:57 网站建设 项目流程

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保存 token
Loading请求超时Error(retryable=true)允许重试
Loading凭据错误Error(retryable=false)不允许重试
Loading用户取消Idle协程取消
Error(retryable)用户点击重试Loading重新发起

这张表的价值在于,它逼你把每个分支都想清楚。Agent 执行也是一样,agent execution terminated due to error这种报错,十有八九是因为状态机里漏了某个分支,导致状态卡死。我在 Harness 层用一个MutableStateFlow<LoginState>来承载这个状态机,所有状态变更都通过一个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 = MutableSharedFlow<HarnessEvent>(extraBufferCapacity = 64) val events: SharedFlow<HarnessEvent> = _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 实例,暴露StateFlow<LoginState>。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 Tool<I, 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 条,足够覆盖一次完整的登录流程。这个双通道日志的设计,让我排查问题的效率至少提高了一倍。

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

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

立即咨询