Readest Android 后台 TTS 锁屏媒体控制修复实战:从 startService 进程内化到 serde 参数陷阱
发布时间:2026/9/20 11:54:59 作者:尧图编辑部 阅读量:1,286

桌面应用跨平台前端【免费下载链接】readestReadest is a modern, feature-rich ebook reader designed for avid readers offering seamless cross-platform access, powerful tools, and an intuitive interface to elevate your reading experience.项目地址https://gitcode.com/gh_mirrors/re/readest点击查看免费下载导读本文基于 Readest 仓库中 android-bg-tts-media-session-fix.md 这份修复记录完整还原一次 Android 端朗读TTS后台播放回归问题的排查与修复全过程从后台startService()被系统拒绝到前台服务FGS从未启动再到Tauri 在 serde 反序列化层直接拒绝命令三层根因的层层剥离并附带锁屏媒体会话MediaSession时长显示与拖动 seek 功能的落地实现。读完你将掌握Android 8 后台服务启动限制BSSR下如何通过进程内调用更新已运行的服务、前台服务通知权限POST_NOTIFICATIONS的正确请求时机、以及 Tauri 移动端命令参数校验失败时如何第一时间从 WebView 控制台定位问题。一、背景一次后台 TTS 回归问题的现场Readest 是跨平台电子书阅读器其朗读Read Aloud功能在移动端通过tauri-plugin-native-tts插件驱动 Android 原生TextToSpeech/ WebAudioEdge 引擎播放并用一个前台服务承载锁屏媒体控制。在两次上游改动合入之后#4941媒体会话与播放会话解耦#4931Edge WebAudio 引擎Android 端出现了明确回归应用退到后台后锁屏媒体控制失效朗读音频直接中断。logcat 中留下了关键报错Not allowed to start service Intent { actUPDATE_PLAYBACK_STATE ... MediaPlaybackService }: app is in background修复工作落在分支fix/android-bg-tts-media-session共 5 个提交PR readest/readest#4994 已合并2026-07-07提交主题15817fc4b进程内 IPCin-process IPC04e4b4fe6媒体会话时长显示 seek67c22b72b前台服务加固 诊断日志27e224bcc移除keepAppInForeground真正的修复a8643ec12Edge 边缘淡入淡出点击修复下文按表象根因 → 更深的根因 → 真正的根因三层展开这与实际排障顺序一致。二、第一层根因Android 8 禁止后台startService()更新服务2.1 问题机制Android 8.0API 26引入后台服务启动限制Background Service Start RestrictionsBSSR应用处于后台时Context.startService()会被系统拒绝并抛出IllegalStateException日志即为开头的 app is in background。除非应用存在已激活的前台服务可以豁免该限制。修复前的NativeTTSPlugin是这样推送媒体会话更新的// 旧实现问题代码每次朗读 mark 都通过 startService 通知服务 activity.startService(Intent(activity, MediaPlaybackService::class.java).apply { action UPDATE_PLAYBACK_STATE // ... })朗读是逐句推进的每个 mark 都要刷新一次锁屏上的播放状态和元数据。应用一旦退到后台每一次逐句更新都会抛异常结果FGS 通知停止刷新锁屏媒体控制变成过期状态音频路由丢失后台播放随之停止。2.2 修复模式永远不要startService()与运行中的服务通信MediaPlaybackService本来就有一套进程内直接调用运行实例的模式静态Volatile instance引用 requestDeactivation()通过主线程Handler投递调用。修复在此基础上补齐了两个伴生入口源码见 MediaPlaybackService.ktcompanion object { Volatile private var instance: MediaPlaybackService? null // 元数据更新刷新静态变量后通过主线程 Handler 投递给运行实例 fun pushMetadata(title: String, artist: String, artwork: Bitmap?) { currentTitle title currentArtist artist if (artwork ! null) currentArtwork artwork val service instance ?: return Handler(Looper.getMainLooper()).post { service.applyMetadata() } } // 播放状态更新position/duration 为 null 时保留最后已知值 fun pushPlaybackState(playing: Boolean, position: Long?, duration: Long?) { if (position ! null) currentPositionMs position if (duration ! null) currentDurationMs duration val service instance ?: return Handler(Looper.getMainLooper()).post { service.applyPlaybackState(playing) } } }配套改动新增私有实例方法applyMetadata()/applyPlaybackState()在主线程把静态值灌入当前会话与通知删除了已死的UPDATE_METADATA/UPDATE_PLAYBACK_STATEintent 分支以及不再使用的serviceScope/kotlinx.coroutines.*导入。调用侧NativeTTSPlugin.kt也同步改为进程内调用Command fun update_media_session_metadata(invoke: Invoke) { // In-process update on the running service; never startService() // — that throws app is in background once backgrounded. MediaPlaybackService.pushMetadata(title, artist, artworkBitmap) invoke.resolve() } Command fun update_media_session_state(invoke: Invoke) { // position and duration are null on a bare play/pause flip; // the service keeps the last known values so the scrubber does not reset. MediaPlaybackService.pushPlaybackState(isPlaying, args.position?.toLong(), args.duration?.toLong()) invoke.resolve() }这里有一个重要的 Android 语义区分startForeground()用于更新一个已经处于前台的服务在后台是允许的而Context.startForegroundService()用于启动一个新服务从后台调用才是受限的。前者恰恰是该修复可行性的前提——服务已经在set_media_session_active时被startForegroundService拉起了。三、第二层根因POST_NOTIFICATIONS从未被请求3.1 问题机制Android 13API 33引入POST_NOTIFICATIONS运行时权限。FGS 的媒体通知也就是锁屏媒体控制的载体若没有该权限会被静默抑制。而 #4941 在TTSMediaBridge.bind()的setActive({active: true})调用中丢弃了keepAppInForeground与通知标题等字段mediaSession.ts中requestPostNotificationPermission()原本由keepAppInForeground门控而keepAppInForeground默认值为false对应constants.ts中的alwaysInForeground设置。于是POST_NOTIFICATIONS从未被请求Android 13 上 FGS 媒体通知 锁屏控制被系统静默抑制。3.2 修复每次激活都请求权限决定一次后即为 no-op在 mediaSession.ts 的setActive()中权限请求改为每次激活都执行系统决定一次后即为 no-op不再受开关门控async setActive(sessionState: MediaSessionState) { if (sessionState.active) { // The foreground-service media notification IS the lock-screen control; // on Android 13 it is silently suppressed unless POST_NOTIFICATIONS is // granted. Request it on every activation (no-op once decided). try { await this.requestPostNotificationPermission(); } catch (error) { console.warn(POST_NOTIFICATIONS request failed:, error); } try { await this.initializeListeners(); } catch (error) { console.warn(Media session listener init failed:, error); } } // ... }注意这里权限请求被放进了独立的 try/catch目的就是best-effort它绝不能因为抛错或阻塞而中断随后必须执行的set_media_session_active即 FGS 启动。这一解耦随后被证明是必要的见下文提交 3。四、第三层真正根因serde 必需字段导致 Tauri 命令在参数层被拒4.1 排障过程的弯路设备实测Xiaomi / MIUItargetSdk 36第一轮就发现了两个问题测试 APK 是旧的logcat 仍显示startService(actUPDATE_METADATA/UPDATE_PLAYBACK_STATE)说明修复根本没编译进 APK很可能从 main 树而非 worktree 构建的更深的问题W/ActivityManager: Stopping service due to app idle: ...MediaPlaybackService—— 服务从未被提升为前台服务FGS 不会被 idle-stop且 readest 的 uid 从未出现在 FGS 类型日志中。MIUI 环境还额外敌对uid 10186SecurityCenter反复把 readest 的post_notificationappop 置为ignore甚至直接Force stopping service。另外音频实际由 WebVieworg.chromium.content.browser.AudioFocusDelegate持有焦点播放而服务的 ExoPlayer 也请求AUDIOFOCUS_GAIN存在疑似焦点抢占冲突未证实。提交 367c22b72b做了前台服务加固 诊断日志这为下一轮排查提供了探针showNotification()改用ServiceCompat.startForeground(this, id, notif, ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK)显式声明 FGS 类型targetSdk 34 必需并用 try/catch Log 包裹见 MediaPlaybackService.ktmediaSession.ts的setActive中POST_NOTIFICATIONS请求独立 try/catch 解耦增加 trace 日志插件侧set_media_session_active: startForegroundService、服务侧activateSession (wasActive)、startForeground ok/failed。4.2 真相命令从未执行第三轮排查中从 WebView 控制台adb logcat的 chromium tag 下的[INFO:CONSOLE]抓到了决定性的错误Failed to set media session active state: invalid args payload for command set_media_session_active: missing field keepAppInForeground根因链条如下Rust 侧SetMediaSessionActiveRequestmodels.rs中keep_app_in_foreground: bool是serde 必需的字段其余字段全部是OptionT#4941 的ttsMediaBridge.bind()发送的是setActive({active: true})没有携带keepAppInForeground于是 Tauri 在serde 反序列化层就拒绝了这次 invokeset_media_session_active命令根本没执行结果FGS 从未启动 → 没有通知 → Android 15 的AS.AudioService: AudioHardening background playback would be muted直接静音后台播放。此前所有修复进程内 IPC、FGS 加固、POST_NOTIFICATIONS 解耦都在这条命令的下游命令不执行它们自然全部无效。诊断陷阱是logcat 中找不到原生 tagMediaPlaybackService/NativeTTSPlugin因为服务从未被触及答案只存在于 WebView JS 控制台grep logcat 的CONSOLE。4.3 修复彻底移除keepAppInForeground提交 427e224bcc按用户要求default true将keepAppInForeground整体删除——它在 Rust / Kotlin / iOS / TS 各层载荷中已无任何读取方FGS 总是启动POST_NOTIFICATIONS变为无条件请求。跟进提交0b8843012进一步清理移除已死的alwaysInForeground设置项及其 Android 设置菜单中的 Background Read Aloud 开关涉及settings.ts/constants.ts/SettingsMenu.tsx及测试通过pnpm i18n:extract清理 33 个语言包中的对应 i18n key。当前 models.rs 中SetMediaSessionActiveRequest已无该字段仅保留active: bool必填与owns_audio_focus、notification_title、notification_text、foreground_service_title、foreground_service_text、book_hash、book_title、book_author全部可选#[derive(Debug, Deserialize, Serialize)] #[serde(rename_all camelCase)] pub struct SetMediaSessionActiveRequest { pub active: bool, // Android: whether the media service should hold the apps audio focus ... pub owns_audio_focus: Optionbool, pub notification_title: OptionString, pub notification_text: OptionString, pub foreground_service_title: OptionString, pub foreground_service_text: OptionString, // Identity of the book being read, persisted so the Android Auto browse // tree can offer a Resume last book entry after the process is cold. pub book_hash: OptionString, pub book_title: OptionString, pub book_author: OptionString, }经验教训Tauri 移动端命令失败时优先抓取 WebView 控制台logcat 的CONSOLEtag——serde 参数拒绝只会在那里显现原生日志中看不到任何痕迹。五、功能增强媒体会话的章节时长显示与拖动 seek修复回归之外本分支还完成了一个用户需求在媒体会话中展示估算的章节时长并支持从锁屏/车载拖动 seek。有趣的是JS 侧早已具备能力原生侧从未使用ttsMediaBridge.#updatePositionState()每个 mark 都会发送{playing, position, duration}毫秒——见 ttsMediaBridge.tsmediaSession.ts早已监听media-session-seek事件 →handlers[seekto]→controller.seekToTime(pos / 1000)——见 mediaSession.ts。原生侧MediaPlaybackService.kt补齐了三处时长元数据buildMediaMetadata()中写入MediaMetadataCompat.METADATA_KEY_DURATION——Android 从 METADATA 读取进度条总长从 PlaybackState 读取滑块位置seek 动作setActions中加入PlaybackStateCompat.ACTION_SEEK_TOseek 回调SessionCallback.onSeekTo(pos)触发pluginEventTrigger(media-session-seek, {position})并把滑块乐观移动到目标位置见 MediaPlaybackService.kt让锁屏在 seek 落地前先有响应override fun onSeekTo(pos: Long) { currentPositionMs pos pluginEventTrigger?.invoke(media-session-seek, JSObject().apply { put(position, pos) }) val state if (player.isPlaying) PlaybackStateCompat.STATE_PLAYING else PlaybackStateCompat.STATE_PAUSED mediaSession?.setPlaybackState(stateBuilder.setState(state, pos, 1f).build()) }两个值得注意的实现细节暂停时进度条不回零纯 play/pause 更新省略了 position/duration因此pushPlaybackState(playing, position: Long?, duration: Long?)在参数为 null 时保留最后一次已知值否则一暂停滑块就跳回 0章节时间线仅限 Edge/WebAudio 引擎TTSController注释明确 position/duration/seek (Edge client only)getPlaybackInfo()对原生TextToSpeech返回 null——原生 TTS 下 duration 保持 0锁屏不出现进度条这是符合预期的行为原生引擎没有可用时间线。JS 侧 seek 事件还有一个值得注意的细节addPluginListener直接交付 payload读取.payload.position反而会抛错导致锁屏/Android Auto 的 seek 永远到不了seekToTime——修复后直接读payload.positionmediaSession.ts。六、配套机制静音保活播放器与音频焦点仲裁理解该服务的设计还需看清两个配套机制均已在 MediaPlaybackService.kt 中实现静音保活播放器。真实 TTS 音频渲染在 WebView或 TextToSpeech中服务内仅用一个循环播放 10 秒silence.mp3的 ExoPlayer 充当静音保活轨道——持有音频路由、驱动会话的 playing/paused 状态activateSession()中player.repeatMode Player.REPEAT_MODE_ONE并playWhenReady true。因此updatePlaybackState()上报的位置必须来自currentPositionMsWebView 的真实进度绝不能读取本地保活播放器的currentPosition——它会饱和在 ~10 秒处把车载/锁屏进度条冻住。音频焦点仲裁。服务用AudioFocusRequestUSAGE_MEDIACONTENT_TYPE_SPEECHsetWillPauseWhenDucked(true)加入有声书契约导航提示等短暂焦点丢失会暂停朗读、焦点恢复后继续永久丢失则保持暂停且不自动恢复。但ownsAudioFocus默认为 true 且存在例外音频由 WebViewaudio元素播放时如听书Chromium 会以同一 uid 请求AUDIOFOCUS_GAIN抢在服务请求之前服务约 15ms 后收到AUDIOFOCUS_LOSS并把media-session-pause转发回 WebView导致听书开始不到一秒就被暂停。此时应用需在setActive时传ownsAudioFocus: false让服务让出焦点仲裁见 mediaSession.ts 的MediaSessionState.ownsAudioFocus注释。七、排障方法论从本案例沉淀的三条经验先验证测试物料本身第一轮设备实测失败是因为 APK 是旧的logcat 中仍出现已被删除的startService调用。设备复测前务必确认构建来自修复分支必要时先adb uninstall再装。MIUI/国产 ROM 需要额外的后台豁免配置Autostart 开启、电池策略无限制、最近任务中锁定。SecurityCenter反复把post_notificationappop 置为 ignore 属于厂商侧干扰排查时要把这类噪音与自身代码问题区分开。Tauri 移动端命令失败先看 WebView 控制台原生 tag 静默 JS 控制台报错logcatCONSOLEtag是 serde 层参数拒绝的典型特征。如果只盯原生日志set_media_session_active这类从未执行的命令会表现得像是执行了但没生效。八、验证与收尾修复完成后执行了完整验证链pnpm test7022 个用例全部通过pnpm lint干净无告警cargo check / fmt / clippy -p tauri-plugin-native-tts全部通过。需要说明的限制合并时Kotlin 代码未经编译与真机验证——worktree 的src-tauri/gen/android缺少tauri.settings.gradle插件依赖的app.tauri.plugin.*无法独立解析需要pnpm tauri android在 Android 13/14 真机上做最终确认logcat 流程前台 → 后台 → 锁屏。该分支的验证记录与后续相关话题会话解耦、Edge WebAudio 引擎、iOS 原生 TTS属于同一系列迭代本文聚焦 Android 侧链路。九、源码地图读者可沿以下路径深入本次修复涉及的完整链路记忆文档android-bg-tts-media-session-fix.md本文章的事实骨架Android 服务实现MediaPlaybackService.kt进程内 push 模式、FGS 加固、seek 回调、音频焦点仲裁插件命令层NativeTTSPlugin.ktset_media_session_active/update_media_session_state/update_media_session_metadataRust 参数模型models.rsserde 字段定义keepAppInForeground已移除Rust 命令分发mobile.rsrun_mobile_plugin桥接TS 媒体会话封装mediaSession.ts权限请求、事件监听、平台分发TS 桥接层ttsMediaBridge.tsbind/unbind、位置状态推送、乐观跳过、保活音频测试mediaSession.test.ts、tts-media-bridge.test.ts结语这次修复最有价值的产出不是某个补丁而是一套可复用的排查范式——后台场景下先怀疑命令是否真的执行了WebView 控制台再怀疑执行的路径是否合法startService 限制最后才是功能层面的表现。三层根因对应三种不同的调试面缺一不可。赞分享桌面应用跨平台前端【免费下载链接】readestReadest is a modern, feature-rich ebook reader designed for avid readers offering seamless cross-platform access, powerful tools, and an intuitive interface to elevate your reading experience.项目地址https://gitcode.com/gh_mirrors/re/readest点击查看免费下载相关推荐解决Redisson XCLAIM参数校验陷阱从报错到修复的完整指南解决Redisson XCLAIM参数校验陷阱从报错到修复的完整指南 你是否遇到过Redisson操作Redis Stream时的参数校验异常在分布式系统中后端缓存数据库客户端分布式Cloudflare Workers 中的 WebSocket 实战Readest 的 fetch 升级模式与 Blob 二进制帧陷阱Cloudflare Workers 中的 WebSocket 实战Readest 的 fetch 升级模式与 Blob 二进制帧陷阱 导读 在 Cloudf桌面应用跨平台前端CF-Workers-Raw终极指南3分钟学会安全访问GitHub私有仓库CF Workers Raw终极指南3分钟学会安全访问GitHub私有仓库 想要安全访问GitHub私有仓库中的原始文件又不想暴露你的GitHub令牌CF上一篇扩展map-vectorizer如何为它添加天窗检测与数字提取新特性下一篇Compile Time Regular Expressions内部架构揭秘从正则表达式到编译时解析的完整流程创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考