多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

跟着Cursor开发安卓APP:详情页语音播报与震动提醒的细节优化

跟着Cursor开发安卓APP:详情页语音播报与震动提醒的细节优化 1. 详情页语音播报与震动提醒从首页传参到真机触发的完整链路安卓 APP 的详情页交互细节往往决定用户会不会长期留下来。我这次用 Cursor 辅助开发一个计数类 APP 的详情页核心目标有三个从首页卡片进入详情页时正确带入目标计数与间隔参数、计数过程中按间隔触发语音播报与震动提醒、计数结束后恢复初始状态并给出完成反馈。听起来简单但真机跑起来后语音没声音、震动图标状态不同步、圆圈动画被半径限制卡死这些问题一个接一个冒出来。这篇文章聚焦「详情页语音播报与震动提醒的触发逻辑」这个场景交付可复制的权限声明、TTS 初始化与震动服务配置片段并给出真机验证步骤。适合正在用 Cursor 或类似 AI 编程工具做安卓 APP、卡在 TTS 异步初始化或 Compose 动画细节上的开发者。全文按「问题场景 → 前置准备 → 可复制配置 → 验证请求 → 常见错排查 → 工具入口」六段展开每一步都能直接跟做。我试过把语音播报和震动提醒拆成两个独立 Manager 类再在 ViewModel 里统一调度这样状态同步和生命周期管理会清晰很多。下面从首页传参这个最容易被忽略的起点讲起。2. 首页传参与详情页初始化Cursor 辅助修复目标计数写死问题从首页点击预设卡片进入详情页后标题、目标计数、计数间隔全部显示为 0 或写死值这是导航路由只传了 templateId 导致的。Cursor 检查 HomeScreen 和 CountingDetailScreen 后指出MainActivity.kt 里导航到 CountingDetailScreen 时只传递了 templateId没有传递模板的其他信息。修复思路是修改导航路由把完整模板信息目标计数、间隔时间、场景类型作为参数传递详情页接收后用这些参数初始化 ViewModel。关键代码如下// MainActivity.kt 导航部分 composable( route counting_detail/{templateId}/{targetCount}/{interval}/{sceneType} ) { backStackEntry - val templateId backStackEntry.arguments?.getString(templateId) ?: val targetCount backStackEntry.arguments?.getString(targetCount)?.toIntOrNull() ?: 108 val interval backStackEntry.arguments?.getString(interval)?.toIntOrNull() ?: 1 val sceneType backStackEntry.arguments?.getString(sceneType) ?: default CountingDetailScreen( templateId templateId, targetCount targetCount, interval interval, sceneType sceneType, onBack { navController.popBackStack() } ) }详情页 ViewModel 需要提供 initialize 方法接收这三个参数并更新 UI 状态。之前编译报Unresolved reference: initialize就是因为 MainActivity 调用了viewModel.initialize()但 ViewModel 里没定义。补上后编译通过// CountingDetailViewModel.kt fun initialize(targetCount: Int, interval: Int, sceneType: String) { _uiState.update { it.copy( targetCount targetCount, currentCount targetCount, interval interval, sceneType sceneType ) } // 同步语音开关状态到 AudioManager避免 UI 与实际播报不一致 audioManager.setVoiceEnabled(_uiState.value.isVoiceEnabled) }这里有个细节计数逻辑要从目标数开始递减到 0而不是从 0 递增。iOS 端 CountDetailViewController.swift 就是这么做的Android 端对齐后currentCount初始值设为目标数每次 tick 减 1减到 0 时触发完成弹窗和「完成」语音。另外语音和震动按钮的默认状态要改为开启。ViewModel 的 UiState 默认值从false改成truedata class CountingDetailUiState( val targetCount: Int 108, val currentCount: Int 108, val interval: Int 1, val isVoiceEnabled: Boolean true, val isHapticEnabled: Boolean true, val countingState: CountingState CountingState.Initial, val showCompletionDialog: Boolean false )这样进入详情页时语音和震动默认就是开启状态用户不需要手动点开。图标方面声音用VolumeUp/VolumeOff震动开启用Vibration震动关闭用MobileOff手机加斜杠比NotificationsOff更贴合「禁止手机震动」的语义。Material Icons 官方没有VibrationOff也没有SettingsOff之前编译报Unresolved reference: SettingsOff就是因为用了不存在的图标名。3. 可复制配置权限声明、TTS 初始化与震动服务片段这一节给出完整的可复制配置包括 AndroidManifest 权限声明、AudioManager 的 TTS 初始化与补播逻辑、HapticManager 的震动服务配置。路径与原文一致直接替换对应文件即可。首先是 AndroidManifest.xml 的权限声明!-- app/src/main/AndroidManifest.xml -- manifest xmlns:androidhttp://schemas.android.com/apk/res/android uses-permission android:nameandroid.permission.VIBRATE / uses-permission android:nameandroid.permission.FOREGROUND_SERVICE / application ... !-- 你的 Activity 声明 -- /application /manifest震动权限是必须的否则Vibrator调用会静默失败。Android 8.0 及以上版本要用VibrationEffect低版本用vibrate(long)已废弃但兼容。接下来是 AudioManager 的 TTS 初始化。核心难点是 TTS 初始化是异步的如果用户在初始化完成前点击开始计数第一次speak会被忽略。解决方案是缓存待播报内容初始化完成后自动补播// AudioManager.kt class AudioManager(private val context: Context) { private var textToSpeech: TextToSpeech? null private var isInitialized false private var isEnabled true private var pendingSpeakText: String? null init { initializeTTS() } private fun initializeTTS() { textToSpeech TextToSpeech(context) { status - if (status TextToSpeech.SUCCESS) { val result textToSpeech?.setLanguage(Locale.CHINESE) if (result TextToSpeech.LANG_MISSING_DATA || result TextToSpeech.LANG_NOT_SUPPORTED ) { Log.e(AudioManager, 中文语音包未安装) } isInitialized true // 初始化完成后补播缓存内容 pendingSpeakText?.let { speak(it) pendingSpeakText null } } } } fun setVoiceEnabled(enabled: Boolean) { isEnabled enabled } fun speak(text: String) { if (!isInitialized || !isEnabled) { pendingSpeakText text return } textToSpeech?.stop() textToSpeech?.speak(text, TextToSpeech.QUEUE_FLUSH, null, utteranceId) } fun speakNumber(number: Int) { speak(number.toString()) } fun release() { textToSpeech?.stop() textToSpeech?.shutdown() textToSpeech null isInitialized false } }注意speak里先调用stop()再speak()避免QUEUE_FLUSH队列冲突导致「只播一次后无声」。release()只在 App 彻底退出时调用不要在 ViewModel 的onCleared()里自动释放否则多次进入详情页会频繁重建 TTS。震动服务配置// HapticManager.kt class HapticManager(private val context: Context) { private val vibrator: Vibrator if (Build.VERSION.SDK_INT Build.VERSION_CODES.S) { val manager context.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager manager.defaultVibrator } else { Suppress(DEPRECATION) context.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator } fun vibrate() { if (Build.VERSION.SDK_INT Build.VERSION_CODES.O) { vibrator.vibrate(VibrationEffect.createOneShot(50, VibrationEffect.DEFAULT_AMPLITUDE)) } else { Suppress(DEPRECATION) vibrator.vibrate(50) } } fun vibrateLong() { if (Build.VERSION.SDK_INT Build.VERSION_CODES.O) { vibrator.vibrate(VibrationEffect.createOneShot(200, VibrationEffect.DEFAULT_AMPLITUDE)) } else { Suppress(DEPRECATION) vibrator.vibrate(200) } } }计数时短震动 50ms完成时长震动 200ms。ViewModel 里调度逻辑// CountingDetailViewModel.kt 的 startCounting fun startCounting() { if (_uiState.value.countingState CountingState.Counting) return countingJob viewModelScope.launch { _uiState.update { it.copy(countingState CountingState.Counting) } while (_uiState.value.currentCount 0) { // 先播报当前数字再递减 if (_uiState.value.isVoiceEnabled) { audioManager.speakNumber(_uiState.value.currentCount) } if (_uiState.value.isHapticEnabled) { hapticManager.vibrate() } delay((_uiState.value.interval * 1000).toLong()) val newCount _uiState.value.currentCount - 1 _uiState.update { it.copy(currentCount newCount) } if (newCount 0) { _uiState.update { it.copy(countingState CountingState.Completed) } if (_uiState.value.isHapticEnabled) hapticManager.vibrateLong() if (_uiState.value.isVoiceEnabled) audioManager.speak(完成) _uiState.update { it.copy(showCompletionDialog true) } break } } } }结束计数和完成弹窗点击「否」时currentCount要恢复为targetCountfun stopCounting() { countingJob?.cancel() _uiState.update { it.copy( countingState CountingState.Initial, currentCount it.targetCount, showCompletionDialog false ) } }4. 验证请求与真机结果语音播报、震动与圆圈动画的预期表现配置写完后真机验证是必须的。模拟器上 TTS 和震动往往表现不完整尤其是震动模拟器基本没反馈。下面是我实测的验证步骤和预期结果。第一步编译并安装到真机。用./gradlew assembleDebug构建或者直接在 Android Studio 点 Run。如果之前改过 Kotlin 版本或 Gradle 插件先执行./gradlew clean再构建避免缓存导致的Unresolved reference误报。第二步从首页点击预设卡片进入详情页。预期标题显示模板名称目标计数显示正确数值比如 108计数间隔显示正确秒数语音和震动图标默认是开启状态VolumeUp 和 Vibration。第三步点击开始计数。预期圆圈开始呼吸动画先放大到 1.3 倍再缩回 1 倍动画周期与 interval 同步interval1.5 秒时放大 0.75 秒、缩小 0.75 秒完整周期 1.5 秒。同时语音播报当前数字震动短震 50ms。数字从目标数递减比如 108、107、106……第四步计数到 0。预期语音播报「完成」震动长震 200ms弹出完成弹窗。点击「否」后目标计数恢复为初始值 108圆圈停止动画恢复静止大小。第五步反复测试多次。预期每次进入详情页都能正常播报不会出现「第一次有声音、之后无声」的情况。如果出现检查是否在onCleared()里调用了audioManager.release()。关于圆圈动画这里有个容易踩的坑。最初用graphicsLayer缩放整个 Canvas导致圆圈内的计数文字也跟着放大缩小而且线宽会随缩放变化。正确做法是只改变drawCircle的半径线宽固定// CountingDetailScreen.kt 圆圈绘制 val strokeWidth 12.dp val strokeWidthPx with(LocalDensity.current) { strokeWidth.toPx() } val breathingScale by if (uiState.countingState CountingState.Counting) { breathingAnim.animateFloat( initialValue 1f, targetValue 1.3f, animationSpec infiniteRepeatable( animation tween((uiState.interval * 1000).toInt(), easing LinearEasing), repeatMode RepeatMode.Reverse ), label breathingScale ) } else { remember { mutableStateOf(1f) } } Canvas(modifier Modifier.fillMaxSize()) { val center this.center val baseRadius (size.minDimension / 2) * 0.85f val radiusPx baseRadius * breathingScale drawCircle( color green.copy(alpha 0.5f), radius radiusPx, center center, style Stroke(width strokeWidthPx) ) }关键点baseRadius用size.minDimension / 2 * 0.85f预留边距radiusPx baseRadius * breathingScale让动画真正影响半径。之前用coerceAtMost(maxRadius)限制半径导致breathingScale 1时被强行截断动画看起来像「放大后暂停」。去掉这个限制改用预留边距的方式动画就自然连贯了。动画节奏方面tween的时长设为interval * 1000配合RepeatMode.Reverse完整呼吸周期就是 interval 秒。如果想让放大和缩小各占一半时长设为interval * 1000 / 2。easing 用LinearEasing避免两端速度变慢造成的停顿感。5. 常见错排查401、local proxy failed、Unresolved reference 与 OAuth 报错对照这一节对照真实报错给出排查路径。如果你在接入 TaoToken 或类似 API 服务时遇到问题也可以参考这里的思路。报错一Unresolved reference: SettingsOff这是 Material Icons 里不存在SettingsOff导致的。Material Icons 官方没有SettingsOff、VibrationOff这些图标。震动关闭用MobileOff或PhonelinkOff声音关闭用VolumeOff。检查 import 语句删除不存在的图标导入。报错二Unresolved reference: initializeMainActivity 调用了viewModel.initialize()但 ViewModel 里没定义这个方法。补上initialize(targetCount, interval, sceneType)方法即可。报错三Unresolved reference: Offset/Unresolved reference: SizeCanvas 绘制时用了Offset和Size但没导入。加上import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size报错四The daemon has terminated unexpectedly on startup attempt #1Kotlin 编译守护进程崩溃通常是 Kapt 与 Kotlin 2.0 版本不兼容。解决方案是降级 Kotlin 到 1.9.22更新 Gradle 和 Android Gradle Plugin 版本优化gradle.properties配置。改完后执行./gradlew clean和rm -rf .gradle build app/build再重新构建。报错五401 Unauthorized如果你在接入 API 服务时遇到 401检查 API Key 是否正确、是否过期、请求头格式是否为Authorization: Bearer key。TaoToken 的 API Key 在控制台生成Base URL 用https://taotoken.net/api不要加 UTM 参数。报错六local proxy failed本地代理失败通常是网络配置问题。检查是否设置了系统代理、代理端口是否被占用。如果你用的是 TaoToken 这类服务确认 Base URL 配置正确不要额外配置代理。报错七reading choices相关错误解析 API 响应时choices字段读取失败通常是响应格式不符合预期。检查请求的 Model ID 是否正确是否用了服务商支持的模型。TaoToken 支持多种模型Model ID 要填对。报错八OAuth 相关报错如果你用 Claude Code 或 Codex 接入OAuth 流程报错时检查回调地址、Client ID、Token 是否过期。Claude Code 的配置在~/.claude/settings.jsonCodex 的配置在~/.codex/auth.json。三件套要写全Base URL、API Key、Model ID。报错九语音播报「只播一次后无声」原因是 TTS 被频繁release()或者QUEUE_FLUSH队列冲突。解决方案不要在 ViewModel 的onCleared()里释放 TTS只在 App 退出时释放每次speak前先stop()。报错十语音图标显示关闭但实际有声音UI 状态和 AudioManager 的isEnabled不同步。在 ViewModel 初始化和initialize()方法里主动同步一次audioManager.setVoiceEnabled(_uiState.value.isVoiceEnabled)。6. 工具入口与长期编码建议如果你在开发过程中需要接入大模型能力比如让 APP 支持智能对话、代码补全或 Agent 调度可以了解 TaoToken 的接入方式。官网入口是 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content API 地址是 https://taotoken.net/api 。对于长期编码和 Agent 场景Coding Plan 适合需要持续调用模型的开发者入口在 https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding-planutm_campaignrewrite 。如果你只是想验证模型对话效果可以用模型对话页面 https://taotoken.net/chat?utm_sourcetaotoken_aicg_blog_endutm_contentchatutm_campaignrewrite 。API Key 在控制台生成 https://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_contentconsoleutm_campaignrewrite 接入文档在 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite 。Claude Code 的接入配置可以参考 https://taotoken.net/claude-code?utm_sourcetaotoken_aicg_blog_endutm_contentclaude-codeutm_campaignrewrite Codex 的配置在 https://taotoken.net/codex?utm_sourcetaotoken_aicg_blog_endutm_contentcodexutm_campaignrewrite 。配置时记得三件套写全Base URL 用https://taotoken.net/apiAPI Key 从控制台获取Model ID 按文档填写。回到安卓开发本身用 Cursor 辅助时遇到编译报错不要只贴错误信息把problems-report.html的路径也给它让它查阅详细错误文档。Android Studio 的报错里通常包含这个路径比如bunnytally_android/build/reports/problems/problems-report.html。另外不要随意更新 Android Studio 插件更新前最好截图问一下 Cursor避免插件冲突花一下午排查。语音播报和震动提醒这类交互细节核心是把异步初始化、状态同步、生命周期管理这三件事处理好。TTS 初始化异步就用缓存补播状态不同步就在初始化和切换时双向同步生命周期就用单例复用避免频繁释放。圆圈动画的关键是只改半径不改线宽用预留边距代替硬性截断。这些细节做到位详情页的体验就接近原生 iOS 了。
返回列表