首页 / 资讯中心 / 文章详情

基于Harness编排与Kotlin协程的Android登录Agent开发实战

基于Harness编排与Kotlin协程的Android登录Agent开发实战 ★ FEATURED ARTICLE
1. 为什么拿 Android 登录模块来搭 Agent 开发环境第一次听到“用 Android 登录模块搭 Agent 开发环境”这个组合很多人会觉得这俩东西八竿子打不着。一个是移动端 UI 层的账号体系一个是当下最热的大模型智能体工程。但真在一线写过 Agent 项目的人会明白登录模块恰好是一个天然的、边界清晰的、带状态流转的“微型 Agent 场景”它有输入账号密码/验证码、有决策分支本地校验、远程校验、风控拦截、有工具调用网络请求、本地存储、生物识别、有失败重试、有超时兜底、有状态机。你把这套东西跑通Agent 开发里 80% 的工程问题——状态管理、异步编排、错误恢复、可观测性——都能在同一个代码库里练一遍。我这次选的是Harness作为 Agent 编排层Kotlin Jetpack Compose作为 Android 侧的实现语言和 UI 框架。Harness 这个词在 Agent 圈子里最近被讨论得很多核心思路是把 Agent 的“执行骨架”和“业务逻辑”解耦骨架负责调度、重试、上下文传递、工具注册业务逻辑只关心“这一步该干什么”。这跟 Android 里把 UI 和 ViewModel 拆开是一个道理。用登录模块来练手的好处是业务逻辑足够简单你能把注意力全放在 Harness 的编排机制上而不是被复杂的业务规则带偏。这篇文章适合三类人看一是写过 Android 但没碰过 Agent 的想找个熟悉的场景切入二是搞过 Agent 但工程化经验不足的想知道怎么把 Agent 塞进一个真实 App 里三是纯粹想学 Kotlin 协程和 Compose 状态管理的登录模块本身就是个绝佳练习场。全文我会按“设计思路 → 核心细节 → 实操落地 → 踩坑排查”的顺序展开每一步都给出可复现的代码和参数你照着抄就能跑起来。提示本文所有代码基于 Kotlin 1.9、Compose BOM 2024 以上版本、Harness 编排层采用通用 Agent 骨架思路实现不依赖任何特定厂商 SDK你可以自由替换成自己团队的技术栈。2. 整体架构设计与技术选型拆解2.1 为什么是 Harness 而不是裸写协程很多人第一反应是登录逻辑我用viewModelScope.launch加几个suspend函数不就搞定了为什么要引入 Harness 这一层我一开始也是这么想的直到登录流程里出现了这些需求验证码发送失败要退避重试、密码错误三次要触发风控、网络超时要降级到离线缓存校验、生物识别失败要回退到密码登录。这些分支如果全写在 ViewModel 里很快就会变成一坨if-else嵌套地狱而且每个分支的重试逻辑都要重写一遍。Harness 的价值就在于把这些“横切关注点”抽出来。它的核心抽象是Step步骤和Runner执行器。一个 Step 定义“做什么”和“失败了怎么办”Runner 负责按顺序执行、处理重试、传递上下文。这跟 Android 里的 WorkManager 思路很像但更轻量而且天然支持挂起函数。方案重试处理状态传递可观测性学习成本裸写协程每个分支手写靠闭包捕获靠打日志低WorkManager内置退避策略靠 Data 传递有系统级日志中Harness 编排声明式配置Context 对象步骤级埋点中选 Harness 的另一个理由是可测试性。登录流程里最烦的就是“验证码发送失败”这种分支裸写的话你得 mock 网络层、mock 计时器。用 Harness 之后每个 Step 都是独立的类你可以单独测它的execute和onFailureRunner 的调度逻辑也能用假 Step 来验证。2.2 Kotlin 协程与 Compose 的配合方式Android 侧我用的是Kotlin 协程 StateFlow Jetpack Compose这套组合。协程负责异步StateFlow 负责状态暴露Compose 负责根据状态渲染 UI。这三者的关系可以这样理解Harness 的 Runner 跑在viewModelScope里每完成一个 Step 就更新一次 StateFlowCompose 监听到变化就重组界面。这里有个关键决策登录状态到底放在 ViewModel 还是放在 Harness 的 Context 里。我的做法是两者都放但职责不同。Harness Context 里放的是“流程内部状态”比如当前重试次数、上次错误码、验证码倒计时ViewModel 的 StateFlow 里放的是“UI 可见状态”比如按钮是否可点、进度条是否显示、错误文案是什么。这样做的原因是流程内部状态变化频率高全部暴露给 UI 会导致过度重组而 UI 状态需要稳定不能因为一次内部重试就闪一下。2.3 模块划分与依赖关系整个项目我拆成四个模块依赖关系是单向的避免循环引用core-harnessAgent 编排骨架包含 Step、Runner、Context、RetryPolicy纯 Kotlin 模块不依赖 Android。feature-login登录业务逻辑包含各个 Step 的实现、LoginViewModel、Compose 界面。>interface StepI, O { val name: String suspend fun execute(input: I, context: AgentContext): O fun onFailure(error: Throwable, context: AgentContext): FailureStrategy }execute是核心逻辑onFailure决定失败后怎么办。FailureStrategy是个密封类有三个分支Retry重试、Fallback降级到备用逻辑、Abort终止流程。这样设计的原因是不同 Step 的失败处理策略完全不同。比如“发送验证码”失败应该重试“校验密码”失败应该直接 Abort 并提示用户“读取本地缓存”失败应该 Fallback 到空值。AgentContext是个可变的上下文对象内部用ConcurrentHashMap存数据因为多个 Step 可能并发执行。它提供get、put、getOrPut等方法还内置了一个traceId用于全链路追踪。注意AgentContext不要存大对象比如 Bitmap 或者大 List。它应该只存轻量的状态数据大对象走专门的 Repository。我踩过的坑是把用户头像的 ByteArray 塞进 Context结果内存抖动明显。3.2 登录流程的 Step 拆解登录流程我拆成六个 Step顺序执行但中间有分支ValidateInputStep校验账号密码格式本地完成不涉及网络。CheckLocalCacheStep查本地是否有有效 token有则跳过后续直接成功。SendVerifyCodeStep如果需要验证码发送验证码带重试。VerifyCodeStep校验用户输入的验证码。RemoteLoginStep调用登录接口带重试和超时。PersistTokenStep把 token 加密存储到本地。每个 Step 的输入输出类型要明确。比如SendVerifyCodeStep输入是手机号输出是SendResult包含是否成功、倒计时秒数。这样类型安全编译期就能发现参数传错的问题。3.3 重试策略的参数计算重试不是简单粗暴地循环。我用的是指数退避 抖动策略。基础延迟 1 秒每次翻倍最大 8 秒再加 0~500ms 的随机抖动。为什么要加抖动因为如果多个用户同时失败重试没有抖动的话会在同一时刻集中请求给服务端造成脉冲压力。计算公式是delay min(baseDelay * 2^attempt, maxDelay) random(0, jitter)。以 attempt 从 0 开始算重试次数基础延迟加抖动后范围第 1 次1s1.0s ~ 1.5s第 2 次2s2.0s ~ 2.5s第 3 次4s4.0s ~ 4.5s第 4 次8s8.0s ~ 8.5s超过 4 次就 Abort提示用户“网络异常请稍后重试”。这个参数不是拍脑袋定的我实测下来移动网络下 4 次重试覆盖了 95% 的临时故障再多重试用户等待时间就太长了。3.4 Compose 状态管理与重组优化Compose 这边最容易踩的坑是过度重组。登录界面有个倒计时按钮如果倒计时状态和输入框状态放在同一个 StateFlow 里每秒倒计时都会导致整个界面重组。我的做法是拆成多个独立的 StateFlowval inputState: StateFlowInputState val countdownState: StateFlowInt val loadingState: StateFlowBoolean val errorState: StateFlowString?然后在 Compose 里用collectAsStateWithLifecycle分别订阅。这样倒计时变化只重组按钮不影响输入框。另外InputState用data class并重写equals避免内容没变也触发重组。提示collectAsStateWithLifecycle比collectAsState更适合 Android因为它会在界面不可见时自动停止收集省电省内存。4. 完整实操流程与关键环节实现4.1 环境准备与依赖配置先把项目骨架搭起来。Android Studio 新建项目选 Empty Compose Activity包名随意。然后在gradle/libs.versions.toml里加依赖[versions] kotlin 1.9.22 composeBom 2024.02.00 coroutines 1.8.0 lifecycle 2.7.0 [libraries] coroutines-core { module org.jetbrains.kotlinx:kotlinx-coroutines-core, version.ref coroutines } coroutines-android { module org.jetbrains.kotlinx:kotlinx-coroutines-android, version.ref coroutines } lifecycle-viewmodel-compose { module androidx.lifecycle:lifecycle-viewmodel-compose, version.ref lifecycle } lifecycle-runtime-compose { module androidx.lifecycle:lifecycle-runtime-compose, version.ref lifecycle }lifecycle-runtime-compose是必须的它提供collectAsStateWithLifecycle。协程版本别用太老的1.7 以下在 Compose 里有些挂起函数的行为不一致。4.2 Harness Runner 的核心实现Runner 的职责是按顺序执行 Step处理失败策略。核心代码如下class AgentRunner(private val steps: ListStepAny, Any) { suspend fun run(context: AgentContext): RunResult { for (step in steps) { var attempt 0 while (true) { try { val input context.getAny(step.name _input) ?: Unit val output step.execute(input, context) context.put(step.name _output, output) break } catch (e: Throwable) { when (val strategy step.onFailure(e, context)) { is FailureStrategy.Retry - { if (attempt strategy.maxAttempt) return RunResult.Failed(e) val delay calculateDelay(attempt, strategy) delay(delay) attempt } is FailureStrategy.Fallback - { context.put(step.name _output, strategy.fallbackValue) break } is FailureStrategy.Abort - return RunResult.Failed(e) } } } } return RunResult.Success(context) } }这里有个细节context.getAny(step.name _input)用 Step 名字做 key 前缀避免不同 Step 的输入输出互相覆盖。实际项目里我会用更严格的类型化 Context但为了演示清晰这里用字符串 key。4.3 登录 ViewModel 的组装ViewModel 负责把 Runner 和 UI 状态连起来class LoginViewModel( private val runner: AgentRunner, private val context: AgentContext ) : ViewModel() { private val _loadingState MutableStateFlow(false) val loadingState: StateFlowBoolean _loadingState.asStateFlow() private val _errorState MutableStateFlowString?(null) val errorState: StateFlowString? _errorState.asStateFlow() fun login(account: String, password: String) { viewModelScope.launch { _loadingState.value true _errorState.value null context.put(account, account) context.put(password, password) when (val result runner.run(context)) { is RunResult.Success - { /* 导航到主页 */ } is RunResult.Failed - { _errorState.value mapErrorToMessage(result.error) } } _loadingState.value false } } }mapErrorToMessage把异常类型映射成用户能看懂的文案。比如TimeoutException映射成“网络超时请检查网络”AuthException映射成“账号或密码错误”。这个映射表要维护好别直接把异常堆栈丢给用户看。4.4 Compose 登录界面的实现界面部分用 Compose 写重点是状态订阅和输入处理Composable fun LoginScreen(viewModel: LoginViewModel) { val loading by viewModel.loadingState.collectAsStateWithLifecycle() val error by viewModel.errorState.collectAsStateWithLifecycle() var account by remember { mutableStateOf() } var password by remember { mutableStateOf() } Column(modifier Modifier.fillMaxSize().padding(24.dp)) { OutlinedTextField( value account, onValueChange { account it }, label { Text(账号) }, singleLine true ) Spacer(Modifier.height(16.dp)) OutlinedTextField( value password, onValueChange { password it }, label { Text(密码) }, visualTransformation PasswordVisualTransformation(), singleLine true ) Spacer(Modifier.height(24.dp)) Button( onClick { viewModel.login(account, password) }, enabled !loading account.isNotBlank() password.isNotBlank() ) { if (loading) CircularProgressIndicator(modifier Modifier.size(20.dp)) else Text(登录) } error?.let { Spacer(Modifier.height(12.dp)) Text(it, color MaterialTheme.colorScheme.error) } } }remember { mutableStateOf() }是 Compose 里管理输入框状态的标准做法。注意enabled条件里加了!loading防止用户重复点击。进度条用CircularProgressIndicator尺寸设成 20dp 放在按钮里比整页 loading 体验好。4.5 本地 Token 加密存储登录成功后 token 要存本地但不能明文存。我用的是 Android Keystore AES-GCM 加密object TokenStore { private const val KEY_ALIAS login_token_key private const val PREF_NAME auth_prefs private const val TOKEN_KEY encrypted_token fun save(context: Context, token: String) { val key getOrCreateKey() val cipher Cipher.getInstance(AES/GCM/NoPadding) cipher.init(Cipher.ENCRYPT_MODE, key) val iv cipher.iv val encrypted cipher.doFinal(token.toByteArray()) val combined iv encrypted context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE) .edit() .putString(TOKEN_KEY, Base64.encodeToString(combined, Base64.NO_WRAP)) .apply() } }IV 要跟密文一起存解密时先取前 12 字节作为 IV。AES-GCM 的 IV 长度固定 12 字节这是规范。别用 ECB 模式那个不安全。5. 常见问题与排查技巧实录5.1 Harness 插件加载失败的排查热词里有个“harness failed to load plugins”这个我实际遇到过。表现是启动时日志报2 entries did not activate插件没生效。排查思路分三步先看插件描述文件里的activation条件是否满足比如依赖的类是否存在再看类加载器是否隔离插件和主程序用了不同的 ClassLoader 会导致类找不到最后看版本兼容性插件编译时的 API 版本和运行时不一致也会静默失败。我的解决办法是在 Runner 初始化时加一个PluginValidator启动时主动检查每个插件的isActive状态不满足就打印具体原因。别等到运行时才发现插件没加载。5.2 协程回调转挂起的常见坑Android 里很多老 API 是回调式的比如BluetoothGattCallback。转成挂起函数时最容易踩的坑是回调可能被调用多次。比如连接失败回调可能触发两次如果你用suspendCancellableCoroutine没做保护第二次resume会抛IllegalStateException。正确做法是用AtomicBoolean标记是否已经 resumesuspend fun connect(): Boolean suspendCancellableCoroutine { cont - val resumed AtomicBoolean(false) callback object : Callback { override fun onSuccess() { if (resumed.compareAndSet(false, true)) cont.resume(true) } override fun onFailure(e: Throwable) { if (resumed.compareAndSet(false, true)) cont.resumeWithException(e) } } }这个模式我封装成了一个工具类所有回调转挂起的地方都用它省得每次重写。5.3 Compose 重组导致的倒计时闪烁倒计时按钮如果直接在 Composable 里用LaunchedEffect加delay循环界面旋转或者后台切回来时倒计时会重置。原因是LaunchedEffect的 key 变了会重启协程。解决办法是把倒计时状态提到 ViewModel 里用StateFlowInt暴露Composable 只负责显示。ViewModel 的生命周期比 Composable 长旋转屏幕不会丢状态。5.4 常见问题速查表问题现象可能原因排查方法解决方案登录按钮点击无反应loading 状态未重置检查 finally 块用 try-finally 确保重置验证码倒计时重置LaunchedEffect key 变化看 key 是否含可变值状态提到 ViewModelToken 解密失败IV 长度不对打印 IV 字节数固定取前 12 字节重试次数超预期多个 Step 共享 attempt检查 attempt 作用域attempt 放在 while 内Compose 界面卡顿过度重组用 Layout Inspector拆分 StateFlow注意Layout Inspector是排查重组问题的利器能看到每个 Composable 的重组次数。重组次数异常高的地方就是优化点。6. 从登录模块扩展到通用 Agent 场景登录模块跑通之后这套 Harness 骨架可以直接复用到其他场景。比如注册流程无非是把 Step 换成“校验手机号 → 发送验证码 → 校验验证码 → 设置密码 → 创建账号”Runner 和 Context 完全不用改。再比如实名认证流程Step 换成“上传身份证 → OCR 识别 → 人脸比对 → 提交审核”也是一样的套路。我实际项目里还用它做过“订单提交”流程Step 包括“校验库存 → 计算优惠 → 创建订单 → 发起支付 → 更新状态”。每个 Step 独立测试失败策略单独配置比原来写在一个方法里清晰太多。关键是要把 Step 的粒度控制好太粗了复用性差太细了调度开销大。我的经验是一个 Step 对应一个明确的业务动作执行时间在 100ms 到 3 秒之间比较合适。另外Harness 的 Context 可以扩展成支持持久化这样流程中断后能恢复。比如用户登录到一半接了个电话回来还能继续。这个功能在长流程场景里很有用实现思路是把 Context 序列化到本地恢复时反序列化回来。不过要注意 Context 里不能存不可序列化的对象。最后分享一个我踩过的坑别在 Step 里直接操作 UI。Step 应该只返回数据UI 更新交给 ViewModel 的 StateFlow。我一开始图省事在 Step 里弹 Toast结果测试时根本没法断言而且 Step 和 Android 框架耦合了没法在纯 JVM 环境跑单元测试。解耦之后所有 Step 都能用runTest快速验证测试速度从分钟级降到秒级。
阅读完成 · 觉得有帮助?
咨询建站